This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -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',

View File

@@ -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');
}
}
}

View File

@@ -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',

View File

@@ -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;
}
}

View File

@@ -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;
}
}