presenter refactoring
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/api/src/domain/protests/ProtestsService.test.ts
Normal file
101
apps/api/src/domain/protests/ProtestsService.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user