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

@@ -1,19 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { vi, type MockedFunction } from 'vitest';
import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ProtestsController } from './ProtestsController';
import { RaceService } from '../race/RaceService';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
describe('ProtestsController', () => {
let controller: ProtestsController;
let raceService: ReturnType<typeof vi.mocked<RaceService>>;
let reviewProtestMock: MockedFunction<ProtestsService['reviewProtest']>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProtestsController],
providers: [
{
provide: RaceService,
provide: ProtestsService,
useValue: {
reviewProtest: vi.fn(),
},
@@ -22,18 +24,98 @@ describe('ProtestsController', () => {
}).compile();
controller = module.get<ProtestsController>(ProtestsController);
raceService = vi.mocked(module.get(RaceService));
const service = module.get(ProtestsService);
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);
describe('reviewProtest', () => {
it('should review protest', async () => {
it('should call service and not throw on success', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = { decision: 'upheld', reason: 'Reason' };
raceService.reviewProtest.mockResolvedValue(undefined);
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: true,
protestId,
stewardId: body.stewardId,
decision: body.decision,
}),
);
await controller.reviewProtest(protestId, body);
expect(raceService.reviewProtest).toHaveBeenCalledWith({ protestId, ...body });
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body });
});
it('should throw NotFoundException when protest is not found', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'PROTEST_NOT_FOUND',
message: 'Protest not found',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException);
});
it('should throw ForbiddenException when steward is not league admin', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'NOT_LEAGUE_ADMIN',
message: 'Not authorized',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException);
});
it('should throw InternalServerErrorException for unexpected error codes', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'UNEXPECTED_ERROR',
message: 'Unexpected',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
});
});

View File

@@ -1,12 +1,12 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam } from '@nestjs/swagger';
import { RaceService } from '../race/RaceService';
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
@ApiTags('protests')
@Controller('protests')
export class ProtestsController {
constructor(private readonly raceService: RaceService) {}
constructor(private readonly protestsService: ProtestsService) {}
@Post(':protestId/review')
@HttpCode(HttpStatus.OK)
@@ -17,6 +17,20 @@ export class ProtestsController {
@Param('protestId') protestId: string,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
): Promise<void> {
return this.raceService.reviewProtest({ protestId, ...body });
const presenter = await this.protestsService.reviewProtest({ protestId, ...body });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
switch (viewModel.errorCode) {
case 'PROTEST_NOT_FOUND':
throw new NotFoundException(viewModel.message ?? 'Protest not found');
case 'RACE_NOT_FOUND':
throw new NotFoundException(viewModel.message ?? 'Race not found for protest');
case 'NOT_LEAGUE_ADMIN':
throw new ForbiddenException(viewModel.message ?? 'Steward is not authorized to review this protest');
default:
throw new InternalServerErrorException(viewModel.message ?? 'Failed to review protest');
}
}
}
}
}

View File

@@ -0,0 +1,101 @@
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 { ProtestsService } from './ProtestsService';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
describe('ProtestsService', () => {
let service: ProtestsService;
let executeMock: MockedFunction<ReviewProtestUseCase['execute']>;
let logger: Logger;
beforeEach(() => {
executeMock = vi.fn();
const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase;
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
service = new ProtestsService(reviewProtestUseCase, logger);
});
const baseCommand = {
protestId: 'protest-1',
stewardId: 'steward-1',
decision: 'uphold' as const,
decisionNotes: 'Notes',
};
const getViewModel = (presenter: ReviewProtestPresenter) => presenter.viewModel;
it('returns presenter with success view model on success', async () => {
executeMock.mockResolvedValue(Result.ok<void, never>(undefined));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(executeMock).toHaveBeenCalledWith(baseCommand);
expect(viewModel).toEqual({
success: true,
protestId: baseCommand.protestId,
stewardId: baseCommand.stewardId,
decision: baseCommand.decision,
});
});
it('maps PROTEST_NOT_FOUND error into presenter', async () => {
executeMock.mockResolvedValue(Result.err({ code: 'PROTEST_NOT_FOUND' as const }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
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 }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
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 }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
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 }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
success: false,
errorCode: 'UNEXPECTED',
message: 'Failed to review protest',
});
});
});

View File

@@ -1,9 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Presenter
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
// Tokens
import { LOGGER_TOKEN } from './ProtestsProviders';
@@ -19,13 +22,41 @@ export class ProtestsService {
stewardId: string;
decision: 'uphold' | 'dismiss';
decisionNotes: string;
}): Promise<void> {
}): Promise<ReviewProtestPresenter> {
this.logger.debug('[ProtestsService] Reviewing protest:', command);
const presenter = new ReviewProtestPresenter();
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to review protest');
const error = result.unwrapErr();
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;
}
}
}

View File

@@ -0,0 +1,45 @@
export interface ReviewProtestViewModel {
success: boolean;
errorCode?: string;
message?: string;
protestId?: string;
stewardId?: string;
decision?: 'uphold' | 'dismiss';
}
export class ReviewProtestPresenter {
private result: ReviewProtestViewModel | null = null;
reset(): void {
this.result = null;
}
presentSuccess(payload: { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss' }): void {
this.result = {
success: true,
protestId: payload.protestId,
stewardId: payload.stewardId,
decision: payload.decision,
};
}
presentError(errorCode: string, message?: string): void {
this.result = {
success: false,
errorCode,
message,
};
}
getViewModel(): ReviewProtestViewModel | null {
return this.result;
}
get viewModel(): ReviewProtestViewModel {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}