refactor use cases

This commit is contained in:
2026-01-08 15:34:51 +01:00
parent d984ab24a8
commit 52e9a2f6a7
362 changed files with 5192 additions and 8409 deletions

View File

@@ -2,21 +2,15 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import {
DeleteMediaUseCase,
type DeleteMediaInput,
type DeleteMediaResult,
type DeleteMediaErrorCode,
} from './DeleteMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> {
present: Mock;
result?: DeleteMediaResult;
}
describe('DeleteMediaUseCase', () => {
let mediaRepo: {
findById: Mock;
@@ -26,7 +20,6 @@ describe('DeleteMediaUseCase', () => {
deleteMedia: Mock;
};
let logger: Logger;
let output: TestOutputPort;
let useCase: DeleteMediaUseCase;
beforeEach(() => {
@@ -46,16 +39,9 @@ describe('DeleteMediaUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: DeleteMediaResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new DeleteMediaUseCase(
mediaRepo as unknown as IMediaRepository,
mediaStorage as unknown as MediaStoragePort,
output,
logger,
);
});
@@ -74,10 +60,9 @@ describe('DeleteMediaUseCase', () => {
{ message: string }
>;
expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('deletes media from storage and repository on success', async () => {
it('returns DeleteMediaResult on success', async () => {
const media = Media.create({
id: 'media-1',
filename: 'file.png',
@@ -98,7 +83,9 @@ describe('DeleteMediaUseCase', () => {
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value);
expect(mediaRepo.delete).toHaveBeenCalledWith('media-1');
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
mediaId: 'media-1',
deleted: true,
});
@@ -117,6 +104,5 @@ describe('DeleteMediaUseCase', () => {
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,7 +6,7 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -30,11 +30,10 @@ export class DeleteMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<DeleteMediaResult>,
private readonly logger: Logger,
) {}
async execute(input: DeleteMediaInput): Promise<Result<void, DeleteMediaApplicationError>> {
async execute(input: DeleteMediaInput): Promise<Result<DeleteMediaResult, DeleteMediaApplicationError>> {
this.logger.info('[DeleteMediaUseCase] Deleting media', {
mediaId: input.mediaId,
});
@@ -43,7 +42,7 @@ export class DeleteMediaUseCase {
const media = await this.mediaRepo.findById(input.mediaId);
if (!media) {
return Result.err<void, DeleteMediaApplicationError>({
return Result.err<DeleteMediaResult, DeleteMediaApplicationError>({
code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' },
});
@@ -52,16 +51,14 @@ export class DeleteMediaUseCase {
await this.mediaStorage.deleteMedia(media.url.value);
await this.mediaRepo.delete(input.mediaId);
this.output.present({
mediaId: input.mediaId,
deleted: true,
});
this.logger.info('[DeleteMediaUseCase] Media deleted successfully', {
mediaId: input.mediaId,
});
return Result.ok(undefined);
return Result.ok({
mediaId: input.mediaId,
deleted: true,
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -69,7 +66,7 @@ export class DeleteMediaUseCase {
mediaId: input.mediaId,
});
return Result.err<void, DeleteMediaApplicationError>({
return Result.err<DeleteMediaResult, DeleteMediaApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message: err.message || 'Unexpected repository error' },
});

View File

@@ -2,27 +2,20 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import {
GetAvatarUseCase,
type GetAvatarInput,
type GetAvatarResult,
type GetAvatarErrorCode,
} from './GetAvatarUseCase';
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Avatar } from '../../domain/entities/Avatar';
interface TestOutputPort extends UseCaseOutputPort<GetAvatarResult> {
present: Mock;
result?: GetAvatarResult;
}
describe('GetAvatarUseCase', () => {
let avatarRepo: {
findActiveByDriverId: Mock;
save: Mock;
};
let logger: Logger;
let output: TestOutputPort;
let useCase: GetAvatarUseCase;
beforeEach(() => {
@@ -38,15 +31,8 @@ describe('GetAvatarUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: GetAvatarResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new GetAvatarUseCase(
avatarRepo as unknown as IAvatarRepository,
output,
logger,
);
});
@@ -65,10 +51,9 @@ describe('GetAvatarUseCase', () => {
{ message: string }
>;
expect(err.code).toBe('AVATAR_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('presents avatar details when avatar exists', async () => {
it('returns GetAvatarResult when avatar exists', async () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
@@ -82,7 +67,9 @@ describe('GetAvatarUseCase', () => {
expect(result.isOk()).toBe(true);
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
avatar: {
id: avatar.id,
driverId: avatar.driverId,
@@ -105,6 +92,5 @@ describe('GetAvatarUseCase', () => {
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -5,7 +5,7 @@
*/
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -32,11 +32,10 @@ export type GetAvatarApplicationError = ApplicationErrorCode<
export class GetAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<GetAvatarResult>,
private readonly logger: Logger,
) {}
async execute(input: GetAvatarInput): Promise<Result<void, GetAvatarApplicationError>> {
async execute(input: GetAvatarInput): Promise<Result<GetAvatarResult, GetAvatarApplicationError>> {
this.logger.info('[GetAvatarUseCase] Getting avatar', {
driverId: input.driverId,
});
@@ -45,13 +44,13 @@ export class GetAvatarUseCase {
const avatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
if (!avatar) {
return Result.err({
return Result.err<GetAvatarResult, GetAvatarApplicationError>({
code: 'AVATAR_NOT_FOUND',
details: { message: 'Avatar not found' },
});
}
this.output.present({
return Result.ok({
avatar: {
id: avatar.id,
driverId: avatar.driverId,
@@ -59,8 +58,6 @@ export class GetAvatarUseCase {
selectedAt: avatar.selectedAt,
},
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -68,7 +65,7 @@ export class GetAvatarUseCase {
driverId: input.driverId,
});
return Result.err({
return Result.err<GetAvatarResult, GetAvatarApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' },
});

View File

@@ -2,26 +2,19 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import {
GetMediaUseCase,
type GetMediaInput,
type GetMediaResult,
type GetMediaErrorCode,
} from './GetMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> {
present: Mock;
result?: GetMediaResult;
}
describe('GetMediaUseCase', () => {
let mediaRepo: {
findById: Mock;
};
let logger: Logger;
let output: TestOutputPort;
let useCase: GetMediaUseCase;
beforeEach(() => {
@@ -36,15 +29,8 @@ describe('GetMediaUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: GetMediaResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new GetMediaUseCase(
mediaRepo as unknown as IMediaRepository,
output,
logger,
);
});
@@ -60,10 +46,9 @@ describe('GetMediaUseCase', () => {
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('presents media details when media exists', async () => {
it('returns GetMediaResult when media exists', async () => {
const media = Media.create({
id: 'media-1',
filename: 'file.png',
@@ -82,7 +67,9 @@ describe('GetMediaUseCase', () => {
expect(result.isOk()).toBe(true);
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
media: {
id: media.id,
filename: media.filename,
@@ -109,4 +96,4 @@ describe('GetMediaUseCase', () => {
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
});
});
});

View File

@@ -5,7 +5,7 @@
*/
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -33,13 +33,12 @@ export type GetMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly output: UseCaseOutputPort<GetMediaResult>,
private readonly logger: Logger,
) {}
async execute(
input: GetMediaInput,
): Promise<Result<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
): Promise<Result<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
this.logger.info('[GetMediaUseCase] Getting media', {
mediaId: input.mediaId,
});
@@ -48,7 +47,7 @@ export class GetMediaUseCase {
const media = await this.mediaRepo.findById(input.mediaId);
if (!media) {
return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
return Result.err<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' },
});
@@ -70,16 +69,14 @@ export class GetMediaUseCase {
mediaResult.metadata = media.metadata;
}
this.output.present({ media: mediaResult });
return Result.ok(undefined);
return Result.ok({ media: mediaResult });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[GetMediaUseCase] Error getting media', err, {
mediaId: input.mediaId,
});
return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
return Result.err<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import {
@@ -16,16 +16,10 @@ vi.mock('uuid', () => ({
v4: () => 'request-1',
}));
interface TestOutputPort extends UseCaseOutputPort<RequestAvatarGenerationResult> {
present: Mock;
result?: RequestAvatarGenerationResult;
}
describe('RequestAvatarGenerationUseCase', () => {
let avatarRepo: { save: Mock };
let faceValidation: { validateFacePhoto: Mock };
let avatarGeneration: { generateAvatars: Mock };
let output: TestOutputPort;
let logger: Logger;
let useCase: RequestAvatarGenerationUseCase;
@@ -42,12 +36,6 @@ describe('RequestAvatarGenerationUseCase', () => {
generateAvatars: vi.fn(),
};
output = {
present: vi.fn((result: RequestAvatarGenerationResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
logger = {
debug: vi.fn(),
info: vi.fn(),
@@ -59,12 +47,11 @@ describe('RequestAvatarGenerationUseCase', () => {
avatarRepo as unknown as IAvatarGenerationRepository,
faceValidation as unknown as FaceValidationPort,
avatarGeneration as unknown as AvatarGenerationPort,
output,
logger,
);
});
it('completes generation and presents avatar URLs', async () => {
it('returns RequestAvatarGenerationResult on success', async () => {
faceValidation.validateFacePhoto.mockResolvedValue({
isValid: true,
hasFace: true,
@@ -92,7 +79,8 @@ describe('RequestAvatarGenerationUseCase', () => {
expect(avatarGeneration.generateAvatars).toHaveBeenCalled();
expect(avatarRepo.save).toHaveBeenCalledTimes(4);
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
requestId: 'request-1',
status: 'completed',
avatarUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
@@ -113,7 +101,7 @@ describe('RequestAvatarGenerationUseCase', () => {
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
};
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -122,7 +110,6 @@ describe('RequestAvatarGenerationUseCase', () => {
expect(err.details?.message).toBe('Bad image');
expect(avatarGeneration.generateAvatars).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
expect(avatarRepo.save).toHaveBeenCalledTimes(3);
});
@@ -145,7 +132,7 @@ describe('RequestAvatarGenerationUseCase', () => {
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
};
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -153,7 +140,6 @@ describe('RequestAvatarGenerationUseCase', () => {
expect(err.code).toBe('GENERATION_FAILED');
expect(err.details?.message).toBe('Generation service down');
expect(output.present).not.toHaveBeenCalled();
expect(avatarRepo.save).toHaveBeenCalledTimes(4);
});
@@ -166,7 +152,7 @@ describe('RequestAvatarGenerationUseCase', () => {
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
};
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -174,7 +160,6 @@ describe('RequestAvatarGenerationUseCase', () => {
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { FaceValidationPort } from '../ports/FaceValidationPort';
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
import type { RacingSuitColor } from '../../domain/types/AvatarGenerationRequest';
import { Result } from '@core/shared/application/Result';
@@ -42,13 +42,12 @@ export class RequestAvatarGenerationUseCase {
private readonly avatarRepo: IAvatarGenerationRepository,
private readonly faceValidation: FaceValidationPort,
private readonly avatarGeneration: AvatarGenerationPort,
private readonly output: UseCaseOutputPort<RequestAvatarGenerationResult>,
private readonly logger: Logger,
) {}
async execute(
input: RequestAvatarGenerationInput,
): Promise<Result<void, RequestAvatarGenerationApplicationError>> {
): Promise<Result<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>> {
this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', {
userId: input.userId,
suitColor: input.suitColor,
@@ -82,7 +81,7 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage);
await this.avatarRepo.save(request);
return Result.err<void, RequestAvatarGenerationApplicationError>({
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
code: 'FACE_VALIDATION_FAILED',
details: { message: errorMessage },
});
@@ -106,7 +105,7 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage);
await this.avatarRepo.save(request);
return Result.err<void, RequestAvatarGenerationApplicationError>({
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
code: 'GENERATION_FAILED',
details: { message: errorMessage },
});
@@ -116,19 +115,17 @@ export class RequestAvatarGenerationUseCase {
request.completeWithAvatars(avatarUrls);
await this.avatarRepo.save(request);
this.output.present({
requestId,
status: 'completed',
avatarUrls,
});
this.logger.info('[RequestAvatarGenerationUseCase] Avatar generation completed', {
requestId,
userId: input.userId,
avatarCount: avatarUrls.length,
});
return Result.ok(undefined);
return Result.ok({
requestId,
status: 'completed',
avatarUrls,
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -136,7 +133,7 @@ export class RequestAvatarGenerationUseCase {
userId: input.userId,
});
return Result.err({
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Internal error occurred during avatar generation' },
});

View File

@@ -1,25 +1,18 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import {
SelectAvatarUseCase,
type SelectAvatarErrorCode,
type SelectAvatarInput,
type SelectAvatarResult,
} from './SelectAvatarUseCase';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
interface TestOutputPort extends UseCaseOutputPort<SelectAvatarResult> {
present: Mock;
result?: SelectAvatarResult;
}
describe('SelectAvatarUseCase', () => {
let avatarRepo: { findById: Mock; save: Mock };
let logger: Logger;
let output: TestOutputPort;
let useCase: SelectAvatarUseCase;
beforeEach(() => {
@@ -35,15 +28,8 @@ describe('SelectAvatarUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: SelectAvatarResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new SelectAvatarUseCase(
avatarRepo as unknown as IAvatarGenerationRepository,
output,
logger,
);
});
@@ -60,7 +46,6 @@ describe('SelectAvatarUseCase', () => {
const err = result.unwrapErr() as ApplicationErrorCode<SelectAvatarErrorCode, { message: string }>;
expect(err.code).toBe('REQUEST_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('returns REQUEST_NOT_COMPLETED when request is not completed', async () => {
@@ -81,10 +66,9 @@ describe('SelectAvatarUseCase', () => {
expect(err.code).toBe('REQUEST_NOT_COMPLETED');
expect(avatarRepo.save).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('selects avatar and presents selected URL when request is completed', async () => {
it('returns SelectAvatarResult when request is completed', async () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
@@ -103,7 +87,9 @@ describe('SelectAvatarUseCase', () => {
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
expect(avatarRepo.save).toHaveBeenCalledWith(request);
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
requestId: 'req-1',
selectedAvatarUrl: 'https://example.com/b.png',
});
@@ -120,7 +106,6 @@ describe('SelectAvatarUseCase', () => {
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@
*/
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -32,11 +32,10 @@ export type SelectAvatarApplicationError = ApplicationErrorCode<
export class SelectAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarGenerationRepository,
private readonly output: UseCaseOutputPort<SelectAvatarResult>,
private readonly logger: Logger,
) {}
async execute(input: SelectAvatarInput): Promise<Result<void, SelectAvatarApplicationError>> {
async execute(input: SelectAvatarInput): Promise<Result<SelectAvatarResult, SelectAvatarApplicationError>> {
this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
requestId: input.requestId,
selectedIndex: input.selectedIndex,
@@ -46,14 +45,14 @@ export class SelectAvatarUseCase {
const request = await this.avatarRepo.findById(input.requestId);
if (!request) {
return Result.err<void, SelectAvatarApplicationError>({
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
code: 'REQUEST_NOT_FOUND',
details: { message: 'Avatar generation request not found' },
});
}
if (request.status !== 'completed') {
return Result.err<void, SelectAvatarApplicationError>({
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
code: 'REQUEST_NOT_COMPLETED',
details: { message: 'Avatar generation is not completed yet' },
});
@@ -64,17 +63,15 @@ export class SelectAvatarUseCase {
const selectedAvatarUrl = request.selectedAvatarUrl!;
this.output.present({
requestId: input.requestId,
selectedAvatarUrl,
});
this.logger.info('[SelectAvatarUseCase] Avatar selected successfully', {
requestId: input.requestId,
selectedAvatarUrl,
});
return Result.ok(undefined);
return Result.ok({
requestId: input.requestId,
selectedAvatarUrl,
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -82,7 +79,7 @@ export class SelectAvatarUseCase {
requestId: input.requestId,
});
return Result.err({
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' },
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import {
@@ -15,15 +15,9 @@ vi.mock('uuid', () => ({
v4: () => 'avatar-1',
}));
interface TestOutputPort extends UseCaseOutputPort<UpdateAvatarResult> {
present: Mock;
result?: UpdateAvatarResult;
}
describe('UpdateAvatarUseCase', () => {
let avatarRepo: { findActiveByDriverId: Mock; save: Mock };
let logger: Logger;
let output: TestOutputPort;
let useCase: UpdateAvatarUseCase;
beforeEach(() => {
@@ -39,15 +33,8 @@ describe('UpdateAvatarUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: UpdateAvatarResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new UpdateAvatarUseCase(
avatarRepo as unknown as IAvatarRepository,
output,
logger,
);
});
@@ -73,7 +60,8 @@ describe('UpdateAvatarUseCase', () => {
expect(saved.mediaUrl.value).toBe('https://example.com/avatar.png');
expect(saved.isActive).toBe(true);
expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' });
const successResult = result.unwrap();
expect(successResult).toEqual({ avatarId: 'avatar-1', driverId: 'driver-1' });
});
it('deactivates current avatar before saving new avatar', async () => {
@@ -105,7 +93,8 @@ describe('UpdateAvatarUseCase', () => {
expect(secondSaved.id).toBe('avatar-1');
expect(secondSaved.isActive).toBe(true);
expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' });
const successResult = result.unwrap();
expect(successResult).toEqual({ avatarId: 'avatar-1', driverId: 'driver-1' });
});
it('returns REPOSITORY_ERROR when repository throws', async () => {
@@ -116,7 +105,7 @@ describe('UpdateAvatarUseCase', () => {
mediaUrl: 'https://example.com/avatar.png',
};
const result: Result<void, ApplicationErrorCode<UpdateAvatarErrorCode, { message: string }>> =
const result: Result<UpdateAvatarResult, ApplicationErrorCode<UpdateAvatarErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -124,7 +113,6 @@ describe('UpdateAvatarUseCase', () => {
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@
* Handles the business logic for updating a driver's avatar.
*/
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { v4 as uuidv4 } from 'uuid';
@@ -32,11 +32,10 @@ export type UpdateAvatarApplicationError = ApplicationErrorCode<
export class UpdateAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<UpdateAvatarResult>,
private readonly logger: Logger,
) {}
async execute(input: UpdateAvatarInput): Promise<Result<void, UpdateAvatarApplicationError>> {
async execute(input: UpdateAvatarInput): Promise<Result<UpdateAvatarResult, UpdateAvatarApplicationError>> {
this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
driverId: input.driverId,
mediaUrl: input.mediaUrl,
@@ -58,17 +57,15 @@ export class UpdateAvatarUseCase {
await this.avatarRepo.save(newAvatar);
this.output.present({
avatarId: avatarId.toString(),
driverId: input.driverId,
});
this.logger.info('[UpdateAvatarUseCase] Avatar updated successfully', {
driverId: input.driverId,
avatarId: avatarId.toString(),
});
return Result.ok(undefined);
return Result.ok({
avatarId: avatarId.toString(),
driverId: input.driverId,
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -76,7 +73,7 @@ export class UpdateAvatarUseCase {
driverId: input.driverId,
});
return Result.err({
return Result.err<UpdateAvatarResult, UpdateAvatarApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Internal error occurred while updating avatar' },
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { Readable } from 'node:stream';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import {
@@ -18,16 +18,10 @@ vi.mock('uuid', () => ({
v4: () => 'media-1',
}));
interface TestOutputPort extends UseCaseOutputPort<UploadMediaResult> {
present: Mock;
result?: UploadMediaResult;
}
describe('UploadMediaUseCase', () => {
let mediaRepo: { save: Mock };
let mediaStorage: { uploadMedia: Mock };
let logger: Logger;
let output: TestOutputPort;
let useCase: UploadMediaUseCase;
const baseFile: MulterFile = {
@@ -59,16 +53,9 @@ describe('UploadMediaUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn((result: UploadMediaResult) => {
output.result = result;
}),
} as unknown as TestOutputPort;
useCase = new UploadMediaUseCase(
mediaRepo as unknown as IMediaRepository,
mediaStorage as unknown as MediaStoragePort,
output,
logger,
);
});
@@ -80,7 +67,7 @@ describe('UploadMediaUseCase', () => {
});
const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' };
const result: Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
const result: Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -89,10 +76,9 @@ describe('UploadMediaUseCase', () => {
expect(err.details?.message).toBe('Upload error');
expect(mediaRepo.save).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('creates media and presents mediaId/url on success (includes metadata)', async () => {
it('returns UploadMediaResult on success (includes metadata)', async () => {
mediaStorage.uploadMedia.mockResolvedValue({
success: true,
url: 'https://example.com/media.png',
@@ -128,7 +114,8 @@ describe('UploadMediaUseCase', () => {
expect(saved.uploadedBy).toBe('user-1');
expect(saved.metadata).toEqual({ foo: 'bar' });
expect(output.present).toHaveBeenCalledWith({
const successResult = result.unwrap();
expect(successResult).toEqual({
mediaId: 'media-1',
url: 'https://example.com/media.png',
});
@@ -142,7 +129,7 @@ describe('UploadMediaUseCase', () => {
mediaRepo.save.mockRejectedValue(new Error('DB error'));
const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' };
const result: Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
const result: Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
@@ -150,7 +137,6 @@ describe('UploadMediaUseCase', () => {
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,7 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
@@ -45,13 +45,12 @@ export class UploadMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<UploadMediaResult>,
private readonly logger: Logger,
) {}
async execute(
input: UploadMediaInput,
): Promise<Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
): Promise<Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
this.logger.info('[UploadMediaUseCase] Starting media upload', {
filename: input.file.originalname,
size: input.file.size,
@@ -74,7 +73,7 @@ export class UploadMediaUseCase {
const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, uploadOptions);
if (!uploadResult.success || !uploadResult.url) {
return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
return Result.err<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
code: 'UPLOAD_FAILED',
details: {
message:
@@ -116,21 +115,20 @@ export class UploadMediaUseCase {
mediaId,
url: uploadResult.url,
};
this.output.present(result);
this.logger.info('[UploadMediaUseCase] Media uploaded successfully', {
mediaId,
url: uploadResult.url,
});
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[UploadMediaUseCase] Error uploading media', err, {
filename: input.file.originalname,
});
return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
return Result.err<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});