refactor
This commit is contained in:
@@ -4,7 +4,7 @@ import { ForbiddenException, InternalServerErrorException, NotFoundException } f
|
||||
import { ProtestsController } from './ProtestsController';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
||||
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
describe('ProtestsController', () => {
|
||||
let controller: ProtestsController;
|
||||
@@ -28,15 +28,7 @@ describe('ProtestsController', () => {
|
||||
reviewProtestMock = vi.mocked(service.reviewProtest);
|
||||
});
|
||||
|
||||
const successPresenter = (viewModel: ReviewProtestPresenter['viewModel']): ReviewProtestPresenter => ({
|
||||
get viewModel() {
|
||||
return viewModel;
|
||||
},
|
||||
getViewModel: () => viewModel,
|
||||
reset: vi.fn(),
|
||||
presentSuccess: vi.fn(),
|
||||
presentError: vi.fn(),
|
||||
} as unknown as ReviewProtestPresenter);
|
||||
const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto;
|
||||
|
||||
describe('reviewProtest', () => {
|
||||
it('should call service and not throw on success', async () => {
|
||||
@@ -48,7 +40,7 @@ describe('ProtestsController', () => {
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
successDto({
|
||||
success: true,
|
||||
protestId,
|
||||
stewardId: body.stewardId,
|
||||
@@ -70,7 +62,7 @@ describe('ProtestsController', () => {
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
successDto({
|
||||
success: false,
|
||||
errorCode: 'PROTEST_NOT_FOUND',
|
||||
message: 'Protest not found',
|
||||
@@ -89,7 +81,7 @@ describe('ProtestsController', () => {
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
successDto({
|
||||
success: false,
|
||||
errorCode: 'NOT_LEAGUE_ADMIN',
|
||||
message: 'Not authorized',
|
||||
@@ -108,7 +100,7 @@ describe('ProtestsController', () => {
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
successDto({
|
||||
success: false,
|
||||
errorCode: 'UNEXPECTED_ERROR',
|
||||
message: 'Unexpected',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalSer
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
@ApiTags('protests')
|
||||
@Controller('protests')
|
||||
@@ -17,19 +18,18 @@ export class ProtestsController {
|
||||
@Param('protestId') protestId: string,
|
||||
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
|
||||
): Promise<void> {
|
||||
const presenter = await this.protestsService.reviewProtest({ protestId, ...body });
|
||||
const viewModel = presenter.viewModel;
|
||||
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body });
|
||||
|
||||
if (!viewModel.success) {
|
||||
switch (viewModel.errorCode) {
|
||||
if (!result.success) {
|
||||
switch (result.errorCode) {
|
||||
case 'PROTEST_NOT_FOUND':
|
||||
throw new NotFoundException(viewModel.message ?? 'Protest not found');
|
||||
throw new NotFoundException(result.message ?? 'Protest not found');
|
||||
case 'RACE_NOT_FOUND':
|
||||
throw new NotFoundException(viewModel.message ?? 'Race not found for protest');
|
||||
throw new NotFoundException(result.message ?? 'Race not found for protest');
|
||||
case 'NOT_LEAGUE_ADMIN':
|
||||
throw new ForbiddenException(viewModel.message ?? 'Steward is not authorized to review this protest');
|
||||
throw new ForbiddenException(result.message ?? 'Steward is not authorized to review this protest');
|
||||
default:
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to review protest');
|
||||
throw new InternalServerErrorException(result.message ?? 'Failed to review protest');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, vi, type MockedFunction } from 'vitest';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import type { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
import type {
|
||||
ReviewProtestUseCase,
|
||||
ReviewProtestResult,
|
||||
ReviewProtestApplicationError,
|
||||
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
||||
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
describe('ProtestsService', () => {
|
||||
let service: ProtestsService;
|
||||
@@ -30,16 +34,21 @@ describe('ProtestsService', () => {
|
||||
decisionNotes: 'Notes',
|
||||
};
|
||||
|
||||
const getViewModel = (presenter: ReviewProtestPresenter) => presenter.viewModel;
|
||||
it('returns DTO with success model on success', async () => {
|
||||
const coreResult: ReviewProtestResult = {
|
||||
leagueId: 'league-1',
|
||||
protestId: baseCommand.protestId,
|
||||
status: 'upheld',
|
||||
stewardId: baseCommand.stewardId,
|
||||
decision: baseCommand.decision,
|
||||
};
|
||||
|
||||
it('returns presenter with success view model on success', async () => {
|
||||
executeMock.mockResolvedValue(Result.ok<void, never>(undefined));
|
||||
executeMock.mockResolvedValue(Result.ok<ReviewProtestResult, ReviewProtestApplicationError>(coreResult));
|
||||
|
||||
const presenter = await service.reviewProtest(baseCommand);
|
||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
||||
const dto = await service.reviewProtest(baseCommand);
|
||||
|
||||
expect(executeMock).toHaveBeenCalledWith(baseCommand);
|
||||
expect(viewModel).toEqual({
|
||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||
success: true,
|
||||
protestId: baseCommand.protestId,
|
||||
stewardId: baseCommand.stewardId,
|
||||
@@ -47,52 +56,69 @@ describe('ProtestsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps PROTEST_NOT_FOUND error into presenter', async () => {
|
||||
executeMock.mockResolvedValue(Result.err({ code: 'PROTEST_NOT_FOUND' as const }));
|
||||
it('maps PROTEST_NOT_FOUND error into DTO', async () => {
|
||||
const error: ReviewProtestApplicationError = {
|
||||
code: 'PROTEST_NOT_FOUND',
|
||||
details: { message: 'Protest not found' },
|
||||
};
|
||||
|
||||
const presenter = await service.reviewProtest(baseCommand);
|
||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
||||
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
const dto = await service.reviewProtest(baseCommand);
|
||||
|
||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||
success: false,
|
||||
errorCode: 'PROTEST_NOT_FOUND',
|
||||
message: 'Protest not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps RACE_NOT_FOUND error into presenter', async () => {
|
||||
executeMock.mockResolvedValue(Result.err({ code: 'RACE_NOT_FOUND' as const }));
|
||||
it('maps RACE_NOT_FOUND error into DTO', async () => {
|
||||
const error: ReviewProtestApplicationError = {
|
||||
code: 'RACE_NOT_FOUND',
|
||||
details: { message: 'Race not found for protest' },
|
||||
};
|
||||
|
||||
const presenter = await service.reviewProtest(baseCommand);
|
||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
||||
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
const dto = await service.reviewProtest(baseCommand);
|
||||
|
||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||
success: false,
|
||||
errorCode: 'RACE_NOT_FOUND',
|
||||
message: 'Race not found for protest',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps NOT_LEAGUE_ADMIN error into presenter', async () => {
|
||||
executeMock.mockResolvedValue(Result.err({ code: 'NOT_LEAGUE_ADMIN' as const }));
|
||||
it('maps NOT_LEAGUE_ADMIN error into DTO', async () => {
|
||||
const error: ReviewProtestApplicationError = {
|
||||
code: 'NOT_LEAGUE_ADMIN',
|
||||
details: { message: 'Steward is not authorized to review this protest' },
|
||||
};
|
||||
|
||||
const presenter = await service.reviewProtest(baseCommand);
|
||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
||||
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
const dto = await service.reviewProtest(baseCommand);
|
||||
|
||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||
success: false,
|
||||
errorCode: 'NOT_LEAGUE_ADMIN',
|
||||
message: 'Steward is not authorized to review this protest',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps unexpected error code into generic failure', async () => {
|
||||
executeMock.mockResolvedValue(Result.err({ code: 'UNEXPECTED' as unknown as never }));
|
||||
it('maps unexpected error code into generic failure DTO', async () => {
|
||||
const error: ReviewProtestApplicationError = {
|
||||
// @ts-expect-error - simulate unexpected error code from core
|
||||
code: 'UNEXPECTED',
|
||||
details: { message: 'Failed to review protest' },
|
||||
};
|
||||
|
||||
const presenter = await service.reviewProtest(baseCommand);
|
||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
||||
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
const dto = await service.reviewProtest(baseCommand);
|
||||
|
||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||
success: false,
|
||||
errorCode: 'UNEXPECTED',
|
||||
message: 'Failed to review protest',
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Logger } from '@core/shared/application/Logger';
|
||||
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
|
||||
// Presenter
|
||||
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
||||
import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
// Tokens
|
||||
import { LOGGER_TOKEN } from './ProtestsProviders';
|
||||
@@ -22,41 +22,14 @@ export class ProtestsService {
|
||||
stewardId: string;
|
||||
decision: 'uphold' | 'dismiss';
|
||||
decisionNotes: string;
|
||||
}): Promise<ReviewProtestPresenter> {
|
||||
}): Promise<ReviewProtestResponseDTO> {
|
||||
this.logger.debug('[ProtestsService] Reviewing protest:', command);
|
||||
|
||||
const presenter = new ReviewProtestPresenter();
|
||||
const result = await this.reviewProtestUseCase.execute(command);
|
||||
const presenter = new ReviewProtestPresenter();
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
presenter.present(result);
|
||||
|
||||
let message: string;
|
||||
switch (error.code) {
|
||||
case 'PROTEST_NOT_FOUND':
|
||||
message = 'Protest not found';
|
||||
break;
|
||||
case 'RACE_NOT_FOUND':
|
||||
message = 'Race not found for protest';
|
||||
break;
|
||||
case 'NOT_LEAGUE_ADMIN':
|
||||
message = 'Steward is not authorized to review this protest';
|
||||
break;
|
||||
default:
|
||||
message = 'Failed to review protest';
|
||||
break;
|
||||
}
|
||||
|
||||
presenter.presentError(error.code, message);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess({
|
||||
protestId: command.protestId,
|
||||
stewardId: command.stewardId,
|
||||
decision: command.decision,
|
||||
});
|
||||
|
||||
return presenter;
|
||||
return presenter.responseModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export interface ReviewProtestViewModel {
|
||||
import type { Result } from '@core/shared/application/Result';
|
||||
import type {
|
||||
ReviewProtestResult,
|
||||
ReviewProtestApplicationError,
|
||||
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
|
||||
export interface ReviewProtestResponseDTO {
|
||||
success: boolean;
|
||||
errorCode?: string;
|
||||
message?: string;
|
||||
@@ -8,38 +14,45 @@ export interface ReviewProtestViewModel {
|
||||
}
|
||||
|
||||
export class ReviewProtestPresenter {
|
||||
private result: ReviewProtestViewModel | null = null;
|
||||
private model: ReviewProtestResponseDTO | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.result = null;
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
presentSuccess(payload: { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss' }): void {
|
||||
this.result = {
|
||||
present(
|
||||
result: Result<ReviewProtestResult, ReviewProtestApplicationError>,
|
||||
): void {
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
|
||||
this.model = {
|
||||
success: false,
|
||||
errorCode: error.code,
|
||||
message: error.details?.message,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const value = result.unwrap();
|
||||
|
||||
this.model = {
|
||||
success: true,
|
||||
protestId: payload.protestId,
|
||||
stewardId: payload.stewardId,
|
||||
decision: payload.decision,
|
||||
protestId: value.protestId,
|
||||
stewardId: value.stewardId,
|
||||
decision: value.decision,
|
||||
};
|
||||
}
|
||||
|
||||
presentError(errorCode: string, message?: string): void {
|
||||
this.result = {
|
||||
success: false,
|
||||
errorCode,
|
||||
message,
|
||||
};
|
||||
getResponseModel(): ReviewProtestResponseDTO | null {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
getViewModel(): ReviewProtestViewModel | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
get viewModel(): ReviewProtestViewModel {
|
||||
if (!this.result) {
|
||||
get responseModel(): ReviewProtestResponseDTO {
|
||||
if (!this.model) {
|
||||
throw new Error('Presenter not presented');
|
||||
}
|
||||
|
||||
return this.result;
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user