diff --git a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java index c53ea14..0cae6b7 100644 --- a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java +++ b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package org.opendevstack.apiservice.projectusers.exception; +import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; import org.opendevstack.apiservice.projectusers.model.FieldError; @@ -32,7 +33,7 @@ * messages. */ @Slf4j -@ControllerAdvice +@ControllerAdvice(assignableTypes = ProjectUserController.class) public class GlobalExceptionHandler { /** @@ -69,17 +70,17 @@ public ResponseEntity handleMethodArgumentNotValidExcep fieldErrors.add(fieldError); }); - String errorMessage = String.format( - ErrorMessages.REQUEST_VALIDATION_FAILED, - fieldErrors.size()); - - ValidationErrorResponse errorResponse = new ValidationErrorResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setFieldErrors(fieldErrors); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUEST_VALIDATION_FAILED, + fieldErrors.size()); + + ValidationErrorResponse errorResponse = new ValidationErrorResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setFieldErrors(fieldErrors); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -96,13 +97,13 @@ public ResponseEntity handleConstraintViolationException( .map(this::formatConstraintViolation) .toList(); - String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -115,8 +116,8 @@ public ResponseEntity handleHttpMessageNotReadableException( log.warn("Invalid request body: {}", ex.getMessage()); - String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; - String errorCode = ErrorCodes.PROJECT_USER_ERROR; + String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; + String errorCode = ErrorCodes.PROJECT_USER_ERROR; Throwable cause = ex.getCause(); @@ -172,15 +173,15 @@ public ResponseEntity handleMissingPathVariableException( log.warn("Missing path variable: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, - ex.getVariableName()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, + ex.getVariableName()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -192,15 +193,15 @@ public ResponseEntity handleMissingServletRequestParameterExcep log.warn("Missing request parameter: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, - ex.getParameterName(), ex.getParameterType()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, + ex.getParameterName(), ex.getParameterType()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -212,17 +213,17 @@ public ResponseEntity handleMethodArgumentTypeMismatchException log.warn("Method argument type mismatch: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, - ex.getName(), - ex.getValue(), - ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.INVALID_ROLE); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, + ex.getName(), + ex.getValue(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.INVALID_ROLE); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -233,11 +234,11 @@ public ResponseEntity handleProjectNotFoundException( ProjectNotFoundException ex) { log.warn("Project not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -249,11 +250,11 @@ public ResponseEntity handleUserNotFoundException( UserNotFoundException ex) { log.warn("User not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -297,11 +298,11 @@ public ResponseEntity handleInvalidRoleException( InvalidRoleException ex) { log.warn("Invalid role: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @@ -313,12 +314,12 @@ public ResponseEntity handleAutomationPlatformException( AutomationPlatformException ex) { log.error("Automation platform error: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); } /** @@ -327,12 +328,12 @@ public ResponseEntity handleAutomationPlatformException( @ExceptionHandler(ProjectUserException.class) public ResponseEntity handleProjectUserException(ProjectUserException ex) { log.error("Project user operation failed: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** @@ -341,12 +342,12 @@ public ResponseEntity handleProjectUserException(ProjectUserExc @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 9c83d30..e5b616a 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -133,8 +133,15 @@ components: projectDescription: type: string description: Description of the project. - required: - - projectName + projectFlavor: + type: string + description: Flavor of the project. Either projectFlavor or configurationItem must be provided. + configurationItem: + type: string + description: Configuration item for the project. Either projectFlavor or configurationItem must be provided. + location: + type: string + description: Location of the project. CreateProjectResponse: type: object properties: diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index fe8012d..89aadde 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -30,9 +31,12 @@ public class ProjectController implements ProjectsApi { private final ProjectsFacade projectsFacade; + private final ProjectRequestValidator projectRequestValidator; + @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { + projectRequestValidator.validate(createProjectRequest); try { return ResponseEntity .status(HttpStatus.OK) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 0000000..d5d4e8d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,59 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.warn("Request body validation error: {}", ex.getMessage()); + + String validationMessage = ex.getBindingResult().getFieldErrors().stream() + .map(this::formatFieldError) + .collect(Collectors.joining("; ")); + + if (validationMessage.isBlank()) { + validationMessage = ErrorKey.BAD_REQUEST_BODY.getMessage(); + } + + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(validationMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ProjectValidationException.class) + public ResponseEntity handleValidationException(ProjectValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey(); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + private String formatFieldError(FieldError error) { + return error.getField() + " " + error.getDefaultMessage(); + } +} + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java new file mode 100644 index 0000000..8b656e5 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java new file mode 100644 index 0000000..65ea765 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -0,0 +1,52 @@ +package org.opendevstack.apiservice.project.validation; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.springframework.stereotype.Component; + +@Component +public class ProjectRequestValidator { + + private static final String PROJECT_KEY_PATTERN = "^[A-Z]{2}[A-Z0-9]{1,8}$"; + private static final String PROJECT_NAME_PATTERN = "^[A-Za-z0-9 ]{0,80}$"; + private static final String PROJECT_DESCRIPTION_PATTERN = "^.{0,255}$"; + + public void validate(CreateProjectRequest request) { + validateProjectKey(request.getProjectKey()); + validateProjectName(request.getProjectName()); + validateProjectDescription(request.getProjectDescription()); + validateFlavorOrConfigItem(request); + } + + private void validateProjectKey(String projectKey) { + if (projectKey != null && !projectKey.matches(PROJECT_KEY_PATTERN)) { + throw new ProjectValidationException(ErrorKey.PROJECT_KEY_INVALID_FORMAT); + } + } + + private void validateProjectName(String projectName) { + if (projectName != null && !projectName.matches(PROJECT_NAME_PATTERN)) { + throw new ProjectValidationException(ErrorKey.PROJECT_NAME_INVALID_FORMAT); + } + } + + private void validateProjectDescription(String projectDescription) { + if (projectDescription != null && !projectDescription.matches(PROJECT_DESCRIPTION_PATTERN)) { + throw new ProjectValidationException(ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT); + } + } + + private void validateFlavorOrConfigItem(CreateProjectRequest request) { + String projectFlavor = request.getProjectFlavor(); + String configurationItem = request.getConfigurationItem(); + + boolean hasFlavor = projectFlavor != null && !projectFlavor.trim().isEmpty(); + boolean hasConfigItem = configurationItem != null && !configurationItem.trim().isEmpty(); + + if (!hasFlavor && !hasConfigItem) { + throw new ProjectValidationException( + ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + ); + } + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index 5e57b72..f40526b 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -1,15 +1,16 @@ package org.opendevstack.apiservice.project.controller; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,22 +20,31 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) class ProjectControllerTest { @Mock private ProjectsFacade projectsFacade; + @Mock + private ProjectRequestValidator projectRequestValidator; + private ProjectController sut; + private AutoCloseable mocks; @BeforeEach void setup() { - sut = new ProjectController(projectsFacade); + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectController(projectsFacade, projectRequestValidator); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); } @Test - void createProject_whenSuccess_thenReturnOk() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_ok_when_creation_succeeds() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); CreateProjectResponse serviceResponse = new CreateProjectResponse(); @@ -54,11 +64,12 @@ void createProject_whenSuccess_thenReturnOk() throws Exception { assertThat(result.getBody().getError()).isNull(); assertThat(result.getBody().getErrorKey()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void createProject_whenProjectCreationException_thenReturnConflict() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_conflict_when_project_creation_exception_is_thrown() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("EXISTING"); when(projectsFacade.createProject(any(CreateProjectRequest.class))) @@ -74,11 +85,12 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void createProject_whenProjectKeyGenerationException_thenReturnInternalServerError() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_internal_server_error_when_project_key_generation_exception_is_thrown() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); when(projectsFacade.createProject(any(CreateProjectRequest.class))) .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); @@ -93,10 +105,11 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void getProject_whenFound_thenReturnOk() throws Exception { + void get_project_returns_ok_when_project_exists() { CreateProjectResponse serviceResponse = new CreateProjectResponse(); serviceResponse.setProjectKey("PROJ01"); serviceResponse.setStatus("Initiated"); @@ -114,7 +127,7 @@ void getProject_whenFound_thenReturnOk() throws Exception { } @Test - void getProject_whenNotFound_thenReturnNotFound() throws Exception { + void get_project_returns_not_found_when_project_does_not_exist() { when(projectsFacade.getProject("UNKNOWN")).thenReturn(null); ResponseEntity result = sut.getProject("UNKNOWN"); @@ -130,7 +143,7 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception { } @Test - void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { + void get_project_returns_internal_server_error_when_service_throws_exception() { when(projectsFacade.getProject(anyString())) .thenThrow(new RuntimeException("Database error")); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 0000000..035a7aa --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,123 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProjectExceptionHandlerTest { + + private ProjectExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_key_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_KEY_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("018", result.getBody().getErrorKey()); + assertEquals("projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_name_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_NAME_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("019", result.getBody().getErrorKey()); + assertEquals("projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_missing_flavor_and_config_item() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("023", result.getBody().getErrorKey()); + assertEquals("Project flavour and config item cannot be both null", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { + CreateProjectRequest target = new CreateProjectRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "createProjectRequest"); + bindingResult.addError(new FieldError("createProjectRequest", "projectName", null, false, null, null, + "must not be null")); + + MethodParameter methodParameter; + try { + methodParameter = new MethodParameter( + ProjectController.class.getMethod("createProject", CreateProjectRequest.class), + 0); + } catch (NoSuchMethodException e) { + fail("Failed to reflect controller method for test: " + e.getMessage()); + return; + } + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("014", result.getBody().getErrorKey()); + assertEquals("projectName must not be null", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } +} + diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index c11a596..89ff398 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -35,7 +35,7 @@ void setup() { @Test void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); ProjectResponse serviceResponse = @@ -58,7 +58,7 @@ void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception @Test void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + CreateProjectRequest request = new CreateProjectRequest(); when(projectService.createProject(org.mockito.ArgumentMatchers.any( ProjectRequest.class))) .thenReturn(null); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java index b7cf95a..76419de 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -26,7 +26,7 @@ void setUp() { @Test void to_service_request_maps_all_fields_correctly() { - CreateProjectRequest apiRequest = new CreateProjectRequest("My Project"); + CreateProjectRequest apiRequest = new CreateProjectRequest(); apiRequest.setProjectKey("PROJ01"); apiRequest.setProjectKeyPattern("SS%06d"); apiRequest.setProjectDescription("A test project"); @@ -36,7 +36,6 @@ void to_service_request_maps_all_fields_correctly() { assertNotNull(result); assertEquals("PROJ01", result.getProjectKey()); assertEquals("SS%06d", result.getProjectKeyPattern()); - assertEquals("My Project", result.getProjectName()); assertEquals("A test project", result.getProjectDescription()); } @@ -48,19 +47,6 @@ void to_service_request_returns_null_when_input_is_null() { assertNull(result); } - - @Test - void to_service_request_maps_only_required_field() { - CreateProjectRequest apiRequest = new CreateProjectRequest("Only Name"); - - ProjectRequest result = projectMapper.toServiceRequest(apiRequest); - - assertNotNull(result); - assertNull(result.getProjectKey()); - assertNull(result.getProjectKeyPattern()); - assertEquals("Only Name", result.getProjectName()); - assertNull(result.getProjectDescription()); - } @Test void to_api_response_maps_all_fields_correctly() { diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java new file mode 100644 index 0000000..bfd18db --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -0,0 +1,97 @@ +package org.opendevstack.apiservice.project.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProjectRequestValidatorTest { + + private ProjectRequestValidator sut; + + @BeforeEach + void setUp() { + sut = new ProjectRequestValidator(); + } + + @Test + void validate_throws_exception_when_project_flavor_and_config_item_both_null() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(null); + request.setConfigurationItem(null); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @Test + void validate_throws_exception_when_project_flavor_and_config_item_both_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(""); + request.setConfigurationItem(" "); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "STANDARD, null", + "null, JIRA", + "STANDARD, JIRA" + }) + void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); + request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); + + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_throws_exception_for_invalid_project_key_format() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("STANDARD"); + request.setProjectKey("invalid-key"); // Invalid format + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.PROJECT_KEY_INVALID_FORMAT, exception.getErrorKey()); + } + + @Test + void validate_throws_exception_for_invalid_project_name_format() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Invalid@Name#"); + request.setProjectFlavor("STANDARD"); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.PROJECT_NAME_INVALID_FORMAT, exception.getErrorKey()); + } +} \ No newline at end of file