From 9e4b4ce325ea3366a987cba23f0306cae79da7e4 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Thu, 19 Mar 2026 21:28:09 +0100 Subject: [PATCH 1/3] Feature/add project request validation and exception handling --- .../advice/ProjectUserExceptionHandler.java} | 198 +++++++++--------- .../ProjectUserExceptionHandlerTest.java} | 80 +++---- api-project/openapi/api-project.yaml | 11 +- .../project/controller/ProjectController.java | 4 + .../advice/ProjectExceptionHandler.java | 59 ++++++ .../exception/ProjectValidationException.java | 15 ++ .../validation/ProjectRequestValidator.java | 52 +++++ .../controller/ProjectControllerTest.java | 39 ++-- .../advice/ProjectExceptionHandlerTest.java | 126 +++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 4 +- .../project/mapper/ProjectMapperTest.java | 16 +- 11 files changed, 430 insertions(+), 174 deletions(-) rename api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/{exception/GlobalExceptionHandler.java => controller/advice/ProjectUserExceptionHandler.java} (65%) rename api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/{exception/GlobalExceptionHandlerTest.java => controller/advice/ProjectUserExceptionHandlerTest.java} (73%) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java 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/controller/advice/ProjectUserExceptionHandler.java similarity index 65% rename from api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java rename to api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandler.java index c53ea14..c07fbce 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/controller/advice/ProjectUserExceptionHandler.java @@ -1,39 +1,46 @@ -package org.opendevstack.apiservice.projectusers.exception; +package org.opendevstack.apiservice.projectusers.controller.advice; -import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; -import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; -import org.opendevstack.apiservice.projectusers.model.FieldError; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; - +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; +import org.opendevstack.apiservice.projectusers.exception.ErrorCodes; +import org.opendevstack.apiservice.projectusers.exception.ErrorMessages; +import org.opendevstack.apiservice.projectusers.exception.InvalidRoleException; +import org.opendevstack.apiservice.projectusers.exception.ProjectNotFoundException; +import org.opendevstack.apiservice.projectusers.exception.ProjectUserException; +import org.opendevstack.apiservice.projectusers.exception.UserNotAuthenticatedException; +import org.opendevstack.apiservice.projectusers.exception.UserNotAuthorizedException; +import org.opendevstack.apiservice.projectusers.exception.UserNotFoundException; +import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; +import org.opendevstack.apiservice.projectusers.model.FieldError; +import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingPathVariableException; import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; - /** - * Global exception handler for the Project Users API. + * Exception handler for the Project Users API. * Provides comprehensive error handling with detailed validation error * messages. */ @Slf4j -@ControllerAdvice -public class GlobalExceptionHandler { +@RestControllerAdvice(assignableTypes = ProjectUserController.class) +public class ProjectUserExceptionHandler { /** * Handles validation errors from @Valid annotations on request bodies. @@ -46,7 +53,6 @@ public ResponseEntity handleMethodArgumentNotValidExcep List fieldErrors = new ArrayList<>(); - // Field validation errors for (org.springframework.validation.FieldError error : ex.getBindingResult().getFieldErrors()) { String fieldName = error.getField(); String errorMessage = error.getDefaultMessage(); @@ -61,7 +67,6 @@ public ResponseEntity handleMethodArgumentNotValidExcep fieldErrors.add(fieldError); } - // Global validation errors ex.getBindingResult().getGlobalErrors().forEach(error -> { FieldError fieldError = new FieldError(); fieldError.setField("object"); @@ -69,17 +74,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 +101,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 +120,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 +177,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 +197,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 +217,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 +238,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 +254,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 +302,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 +318,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 +332,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 +346,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); } /** @@ -376,4 +381,5 @@ private String getFieldPath(List path) { .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "[" + ref.getIndex() + "]") .collect(Collectors.joining(".")); } -} \ No newline at end of file +} + diff --git a/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java b/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java similarity index 73% rename from api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java rename to api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java index 8938198..c5f9834 100644 --- a/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java +++ b/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java @@ -1,42 +1,44 @@ -package org.opendevstack.apiservice.projectusers.exception; - +package org.opendevstack.apiservice.projectusers.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.projectusers.controller.ProjectUserController; import org.opendevstack.apiservice.projectusers.model.AddUserToProjectRequest; import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +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 org.springframework.core.MethodParameter; - import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** - * Unit test class for the GlobalExceptionHandler to verify improved validation + * Unit test class for the ProjectUserExceptionHandler to verify improved validation * error messages. */ -class GlobalExceptionHandlerTest { - - private GlobalExceptionHandler exceptionHandler; - +class ProjectUserExceptionHandlerTest { + private ProjectUserExceptionHandler sut; + private AutoCloseable mocks; @BeforeEach void setUp() { - exceptionHandler = new GlobalExceptionHandler(); + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectUserExceptionHandler(); + } + @AfterEach + void tearDown() throws Exception { + mocks.close(); } - @Test - void testValidationErrorHandling() { - // Create a mock MethodArgumentNotValidException with validation errors - // Target object representing the @RequestBody argument + void handle_method_argument_not_valid_exception_returns_bad_request_with_field_errors() { + // GIVEN AddUserToProjectRequest target = new AddUserToProjectRequest(); BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "addUserToProjectRequest"); - - // Add field errors for required fields bindingResult.addError(new FieldError("addUserToProjectRequest", "environment", null, false, null, null, "Environment cannot be blank")); bindingResult.addError( @@ -45,45 +47,32 @@ void testValidationErrorHandling() { "Account cannot be blank")); bindingResult.addError( new FieldError("addUserToProjectRequest", "role", null, false, null, null, "Role cannot be null")); - - // Create a MethodParameter referencing the controller method's @RequestBody - // parameter MethodParameter methodParameter; try { methodParameter = new MethodParameter( ProjectUserController.class.getMethod( "triggerMembershipRequest", String.class, AddUserToProjectRequest.class), - 1 // index of AddUserToProjectRequest parameter - ); + 1); } catch (NoSuchMethodException e) { fail("Failed to reflect controller method for test: " + e.getMessage()); - return; // unreachable, but required for compilation + return; } - MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); - - // Test the exception handler - ResponseEntity response = exceptionHandler - .handleMethodArgumentNotValidException(exception); - - // Verify response + // WHEN + ResponseEntity response = sut.handleMethodArgumentNotValidException(exception); + // THEN assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); assertNotNull(response.getBody()); - ValidationErrorResponse errorResponse = response.getBody(); assertFalse(errorResponse.getSuccess()); assertEquals("PROJECT_USER_ERROR", errorResponse.getErrorCode()); assertNotNull(errorResponse.getFieldErrors()); assertEquals(4, errorResponse.getFieldErrors().size()); - - // Check specific field errors List fieldErrors = errorResponse.getFieldErrors(); assertTrue(fieldErrors.stream().anyMatch(error -> "environment".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "user".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "account".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "role".equals(error.getField()))); - - // Verify expected format is provided for each field fieldErrors.forEach(fieldError -> { assertNotNull(fieldError.getField()); assertNotNull(fieldError.getMessage()); @@ -93,15 +82,14 @@ void testValidationErrorHandling() { } }); } - @Test - void testGenericExceptionHandling() { - // Test generic exception handling + void handle_generic_exception_returns_internal_server_error() { + // GIVEN Exception exception = new RuntimeException("Unexpected error"); - - ResponseEntity response = exceptionHandler.handleGenericException(exception); - + // WHEN + ResponseEntity response = sut.handleGenericException(exception); + // THEN assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); assertNotNull(response.getBody()); } -} \ No newline at end of file +} 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..f05b967 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,126 @@ +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() { + // GIVEN + 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); + + // WHEN + ResponseEntity result = sut.handleMethodArgumentNotValidException(exception); + + // THEN + 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() { From 3058df62f64cb841def05f8de8d27f28e13f637c Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Fri, 20 Mar 2026 12:40:38 +0100 Subject: [PATCH 2/3] Feature/add global exception handling and validation error responses --- .../GlobalExceptionHandler.java} | 35 +++--- .../GlobalExceptionHandlerTest.java} | 80 +++++++------ .../advice/ProjectExceptionHandlerTest.java | 3 - .../ProjectRequestValidatorTest.java | 110 ++++++++++++++++++ 4 files changed, 171 insertions(+), 57 deletions(-) rename api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/{controller/advice/ProjectUserExceptionHandler.java => exception/GlobalExceptionHandler.java} (94%) rename api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/{controller/advice/ProjectUserExceptionHandlerTest.java => exception/GlobalExceptionHandlerTest.java} (73%) create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java diff --git a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandler.java b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java similarity index 94% rename from api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandler.java rename to api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java index c07fbce..0cae6b7 100644 --- a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandler.java +++ b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java @@ -1,46 +1,40 @@ -package org.opendevstack.apiservice.projectusers.controller.advice; +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; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; -import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; -import org.opendevstack.apiservice.projectusers.exception.ErrorCodes; -import org.opendevstack.apiservice.projectusers.exception.ErrorMessages; -import org.opendevstack.apiservice.projectusers.exception.InvalidRoleException; -import org.opendevstack.apiservice.projectusers.exception.ProjectNotFoundException; -import org.opendevstack.apiservice.projectusers.exception.ProjectUserException; -import org.opendevstack.apiservice.projectusers.exception.UserNotAuthenticatedException; -import org.opendevstack.apiservice.projectusers.exception.UserNotAuthorizedException; -import org.opendevstack.apiservice.projectusers.exception.UserNotFoundException; -import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; -import org.opendevstack.apiservice.projectusers.model.FieldError; -import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingPathVariableException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; + /** - * Exception handler for the Project Users API. + * Global exception handler for the Project Users API. * Provides comprehensive error handling with detailed validation error * messages. */ @Slf4j -@RestControllerAdvice(assignableTypes = ProjectUserController.class) -public class ProjectUserExceptionHandler { +@ControllerAdvice(assignableTypes = ProjectUserController.class) +public class GlobalExceptionHandler { /** * Handles validation errors from @Valid annotations on request bodies. @@ -53,6 +47,7 @@ public ResponseEntity handleMethodArgumentNotValidExcep List fieldErrors = new ArrayList<>(); + // Field validation errors for (org.springframework.validation.FieldError error : ex.getBindingResult().getFieldErrors()) { String fieldName = error.getField(); String errorMessage = error.getDefaultMessage(); @@ -67,6 +62,7 @@ public ResponseEntity handleMethodArgumentNotValidExcep fieldErrors.add(fieldError); } + // Global validation errors ex.getBindingResult().getGlobalErrors().forEach(error -> { FieldError fieldError = new FieldError(); fieldError.setField("object"); @@ -381,5 +377,4 @@ private String getFieldPath(List path) { .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "[" + ref.getIndex() + "]") .collect(Collectors.joining(".")); } -} - +} \ No newline at end of file diff --git a/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java b/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java similarity index 73% rename from api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java rename to api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java index c5f9834..8938198 100644 --- a/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java +++ b/api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java @@ -1,44 +1,42 @@ -package org.opendevstack.apiservice.projectusers.controller.advice; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockitoAnnotations; +package org.opendevstack.apiservice.projectusers.exception; + import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; import org.opendevstack.apiservice.projectusers.model.AddUserToProjectRequest; import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; -import org.springframework.core.MethodParameter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; 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 org.springframework.core.MethodParameter; + import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; + +import static org.junit.jupiter.api.Assertions.*; + /** - * Unit test class for the ProjectUserExceptionHandler to verify improved validation + * Unit test class for the GlobalExceptionHandler to verify improved validation * error messages. */ -class ProjectUserExceptionHandlerTest { - private ProjectUserExceptionHandler sut; - private AutoCloseable mocks; +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler exceptionHandler; + @BeforeEach void setUp() { - mocks = MockitoAnnotations.openMocks(this); - sut = new ProjectUserExceptionHandler(); - } - @AfterEach - void tearDown() throws Exception { - mocks.close(); + exceptionHandler = new GlobalExceptionHandler(); } + @Test - void handle_method_argument_not_valid_exception_returns_bad_request_with_field_errors() { - // GIVEN + void testValidationErrorHandling() { + // Create a mock MethodArgumentNotValidException with validation errors + // Target object representing the @RequestBody argument AddUserToProjectRequest target = new AddUserToProjectRequest(); BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "addUserToProjectRequest"); + + // Add field errors for required fields bindingResult.addError(new FieldError("addUserToProjectRequest", "environment", null, false, null, null, "Environment cannot be blank")); bindingResult.addError( @@ -47,32 +45,45 @@ void handle_method_argument_not_valid_exception_returns_bad_request_with_field_e "Account cannot be blank")); bindingResult.addError( new FieldError("addUserToProjectRequest", "role", null, false, null, null, "Role cannot be null")); + + // Create a MethodParameter referencing the controller method's @RequestBody + // parameter MethodParameter methodParameter; try { methodParameter = new MethodParameter( ProjectUserController.class.getMethod( "triggerMembershipRequest", String.class, AddUserToProjectRequest.class), - 1); + 1 // index of AddUserToProjectRequest parameter + ); } catch (NoSuchMethodException e) { fail("Failed to reflect controller method for test: " + e.getMessage()); - return; + return; // unreachable, but required for compilation } + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); - // WHEN - ResponseEntity response = sut.handleMethodArgumentNotValidException(exception); - // THEN + + // Test the exception handler + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Verify response assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); assertNotNull(response.getBody()); + ValidationErrorResponse errorResponse = response.getBody(); assertFalse(errorResponse.getSuccess()); assertEquals("PROJECT_USER_ERROR", errorResponse.getErrorCode()); assertNotNull(errorResponse.getFieldErrors()); assertEquals(4, errorResponse.getFieldErrors().size()); + + // Check specific field errors List fieldErrors = errorResponse.getFieldErrors(); assertTrue(fieldErrors.stream().anyMatch(error -> "environment".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "user".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "account".equals(error.getField()))); assertTrue(fieldErrors.stream().anyMatch(error -> "role".equals(error.getField()))); + + // Verify expected format is provided for each field fieldErrors.forEach(fieldError -> { assertNotNull(fieldError.getField()); assertNotNull(fieldError.getMessage()); @@ -82,14 +93,15 @@ void handle_method_argument_not_valid_exception_returns_bad_request_with_field_e } }); } + @Test - void handle_generic_exception_returns_internal_server_error() { - // GIVEN + void testGenericExceptionHandling() { + // Test generic exception handling Exception exception = new RuntimeException("Unexpected error"); - // WHEN - ResponseEntity response = sut.handleGenericException(exception); - // THEN + + ResponseEntity response = exceptionHandler.handleGenericException(exception); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); assertNotNull(response.getBody()); } -} +} \ No newline at end of file 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 index f05b967..035a7aa 100644 --- 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 @@ -90,7 +90,6 @@ void handle_validation_exception_returns_bad_request_response_for_missing_flavor @Test void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { - // GIVEN CreateProjectRequest target = new CreateProjectRequest(); BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "createProjectRequest"); bindingResult.addError(new FieldError("createProjectRequest", "projectName", null, false, null, null, @@ -108,10 +107,8 @@ void handle_method_argument_not_valid_exception_returns_bad_request_response_for MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); - // WHEN ResponseEntity result = sut.handleMethodArgumentNotValidException(exception); - // THEN assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); assertNotNull(result.getBody()); assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); 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..5270d36 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -0,0 +1,110 @@ +package org.opendevstack.apiservice.project.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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()); + } + + @Test + void validate_succeeds_when_project_flavor_provided() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("STANDARD"); + request.setConfigurationItem(null); + + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_succeeds_when_config_item_provided() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(null); + request.setConfigurationItem("JIRA"); + + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_succeeds_when_both_flavor_and_config_item_provided() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("STANDARD"); + request.setConfigurationItem("JIRA"); + + 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 From 0027df084948441602337c8d23af2e0cfa5a3b6c Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Fri, 20 Mar 2026 12:55:01 +0100 Subject: [PATCH 3/3] Fixed Sonaq warning --- .../ProjectRequestValidatorTest.java | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) 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 index 5270d36..bfd18db 100644 --- 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 @@ -2,6 +2,8 @@ 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; @@ -49,32 +51,17 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); } - @Test - void validate_succeeds_when_project_flavor_provided() { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectName("Valid Name"); - request.setProjectFlavor("STANDARD"); - request.setConfigurationItem(null); - - assertDoesNotThrow(() -> sut.validate(request)); - } - - @Test - void validate_succeeds_when_config_item_provided() { + @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); - request.setConfigurationItem("JIRA"); - - assertDoesNotThrow(() -> sut.validate(request)); - } - - @Test - void validate_succeeds_when_both_flavor_and_config_item_provided() { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectName("Valid Name"); - request.setProjectFlavor("STANDARD"); - request.setConfigurationItem("JIRA"); + request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); + request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); assertDoesNotThrow(() -> sut.validate(request)); }