From 9e4b4ce325ea3366a987cba23f0306cae79da7e4 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Thu, 19 Mar 2026 21:28:09 +0100 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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)); } From 6c541abd61d2312a789eee20630ecb62b32f3d34 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 24 Mar 2026 15:36:36 +0100 Subject: [PATCH 04/14] Feature/add client app registration and project creation enhancements --- api-project/openapi/api-project.yaml | 11 +- api-project/pom.xml | 13 + .../project/controller/ProjectController.java | 16 +- .../controller/ProjectResponseFactory.java | 8 + .../advice/ProjectExceptionHandler.java | 13 + .../ClientAppNotRegisteredException.java | 8 + .../project/exception/ErrorKey.java | 5 +- .../project/facade/ProjectsFacade.java | 7 +- .../facade/impl/ProjectsFacadeImpl.java | 180 ++++++- .../project/service/ClientAppService.java | 29 + .../validation/ProjectRequestValidator.java | 29 + .../controller/ProjectControllerTest.java | 12 +- .../facade/impl/ProjectsFacadeImplTest.java | 498 ++++++++++++++++-- .../project/mapper/ProjectMapperTest.java | 2 - .../project/service/ClientAppServiceTest.java | 56 ++ .../persistence/entity/ClientAppEntity.java | 104 ++++ .../entity/ClientAppProjectFlavorEntity.java | 110 ++++ .../repository/ClientAppRepository.java | 19 + service-projects/pom.xml | 8 +- .../mapper/ProjectResponseMapper.java | 1 + .../serviceproject/model/ProjectRequest.java | 24 +- .../serviceproject/model/ProjectResponse.java | 10 + .../service/impl/ProjectServiceImpl.java | 39 +- .../service/impl/ProjectServiceImplTest.java | 44 -- 24 files changed, 1120 insertions(+), 126 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index e5b616a..8503cee 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -124,9 +124,6 @@ components: projectKey: type: string description: Optional project key. If not provided, a unique key will be generated. - projectKeyPattern: - type: string - description: Optional pattern for generating the project key (e.g. 'SS%06d'). projectName: type: string description: Name of the project. @@ -142,11 +139,19 @@ components: location: type: string description: Location of the project. + x2OdsAccount: + type: string + description: X2ODS account for the project. + owner: + type: string + description: Owner of the project. CreateProjectResponse: type: object properties: projectKey: type: string + httStatus: + type: string status: type: string projectFlavor: diff --git a/api-project/pom.xml b/api-project/pom.xml index e77eda9..3a7962e 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -33,6 +33,13 @@ org.springframework.boot spring-boot-starter-validation + + + org.springframework.boot + spring-boot-devtools + runtime + true + org.springdoc @@ -44,6 +51,12 @@ service-projects ${project.version} + + + org.opendevstack.apiservice + persistence + ${project.version} + io.jsonwebtoken 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 89aadde..49385d4 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 @@ -3,7 +3,9 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.api.ProjectsApi; +import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; @@ -19,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequestMapping(ProjectController.API_BASE_PATH) @AllArgsConstructor @@ -38,10 +42,15 @@ public class ProjectController implements ProjectsApi { public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); try { + UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); + projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); + projectResponse.setErrorKey(ErrorKey.OK.getKey()); + projectResponse.setProjectKey(null); return ResponseEntity .status(HttpStatus.OK) .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(projectsFacade.createProject(createProjectRequest)); + .body(projectResponse); } catch (ProjectCreationException e) { log.error("Project creation conflict: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT) @@ -52,6 +61,11 @@ public ResponseEntity createProject(@Valid @RequestBody C return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .header(HTTP_HEADER_LOCATION, API_BASE_PATH) .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); + } catch (AutomationPlatformException e) { + log.error("Failed to execute automated job: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(ProjectResponseFactory.internalError(API_BASE_PATH, e.getMessage())); } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java index b1ed5d2..caf14c9 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -39,6 +39,14 @@ public static CreateProjectResponse internalError(String location) { location); } + public static CreateProjectResponse internalError(String location, String message) { + return error( + ErrorKey.INTERNAL_ERROR.getMessage(), + ErrorKey.INTERNAL_ERROR.getKey(), + message, + location); + } + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); 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 index d5d4e8d..cc31cc5 100644 --- 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 @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -39,6 +40,18 @@ public ResponseEntity handleMethodArgumentNotValidExcepti return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + @ExceptionHandler(ClientAppNotRegisteredException.class) + public ResponseEntity handleClientAppNotRegisteredException( + ClientAppNotRegisteredException ex) { + log.warn("ClientApp registration error: {}", ex.getMessage()); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.FORBIDDEN.getReasonPhrase()); + response.setErrorKey(ErrorKey.CLIENT_APP_NOT_REGISTERED.getKey()); + response.setMessage(ErrorKey.CLIENT_APP_NOT_REGISTERED.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + @ExceptionHandler(ProjectValidationException.class) public ResponseEntity handleValidationException(ProjectValidationException ex) { log.warn("Validation error: {}", ex.getMessage()); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java new file mode 100644 index 0000000..a3b8c75 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ClientAppNotRegisteredException extends RuntimeException { + + public ClientAppNotRegisteredException(String clientId) { + super(String.format("ClientApp with clientId '%s' is not registered", clientId)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index 4ee644d..fd7904b 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -28,7 +28,10 @@ public enum ErrorKey { BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), PROJECT_ALREADY_EXISTS("025", "Project already exists"), - PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"); + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"), + CLIENT_APP_NOT_REGISTERED("027", "ClientApp not registered, manual registration required"), + INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index b30ad4f..408dee0 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,14 +1,17 @@ package org.opendevstack.apiservice.project.facade; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import java.util.UUID; + public interface ProjectsFacade { - CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException; + CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) + throws ProjectCreationException, ProjectKeyGenerationException, AutomationPlatformException; CreateProjectResponse getProject(String projectKey); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index ea66ee8..197f2ea 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,35 +1,207 @@ package org.opendevstack.apiservice.project.facade.impl; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + @Component("apiProjectFacadeImpl") +@Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { + @Value("${apis.projects.ansible-workflow-name}") + private String createProjectWorkflow; + private final ProjectService projectService; + private final ProjectMapper projectMapper; + + private final ClientAppService clientAppService; + + private final GenerateProjectKeyService generateProjectKeyService; + + private final AutomationPlatformService automationPlatformService; public ProjectsFacadeImpl( ProjectService projectService, - ProjectMapper projectMapper) { + ProjectMapper projectMapper, + ClientAppService clientAppService, + GenerateProjectKeyService generateProjectKeyService, + AutomationPlatformService automationPlatformService) { this.projectService = projectService; this.projectMapper = projectMapper; + this.clientAppService = clientAppService; + this.generateProjectKeyService = generateProjectKeyService; + this.automationPlatformService = automationPlatformService; } @Override - public CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException { - return projectMapper.toApiResponse(projectService.createProject(projectMapper.toServiceRequest(request))); + public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) + throws ProjectCreationException, ProjectKeyGenerationException, AutomationPlatformException { + + ClientAppEntity clientApp = clientAppService.findByClientId(clientId); + + ClientAppProjectFlavorEntity resolvedFlavor = resolveFlavor(request, clientApp); + + resolveRequestDefaults(request, resolvedFlavor); + + String resolvedProjectKey = resolveProjectKey(request.getProjectKey(), resolvedFlavor); + request.setProjectKey(resolvedProjectKey); + + ProjectResponse project = projectService.createProject(projectMapper.toServiceRequest(request)); + + Map parameters = new HashMap<>(); + parameters.put("geographic_region", request.getLocation()); + parameters.put("project_flavor", request.getProjectFlavor()); + parameters.put("project_owner", request.getOwner()); + parameters.put("project_id", project.getProjectId().toString()); + parameters.put("configuration_item", request.getConfigurationItem()); + parameters.put("project_key", request.getProjectKey()); + parameters.put("special_account", request.getX2OdsAccount()); + parameters.put("description", request.getProjectDescription()); + parameters.put("project_name", request.getProjectName()); + parameters.put("client_id", clientId.toString()); + + AutomationExecutionResult automationExecutionResult = automationPlatformService + .executeWorkflow(createProjectWorkflow, parameters); + + if (automationExecutionResult.isSuccessful()) { + CreateProjectResponse response = new CreateProjectResponse(); + response.setMessage("The project creation process has been successfully initiated."); + response.setStatus(Status.PENDING.getDbValue()); + response.setProjectFlavor(request.getProjectFlavor()); + response.setProjectKey(project.getProjectKey()); + return response; + } else { + throw new AutomationPlatformException("Failed to create project: " + + automationExecutionResult.getErrorDetails()); + } + } + + private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { + List flavors = clientApp.getProjectFlavors(); + if (flavors == null || flavors.isEmpty()) { + log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); + throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + } + + if (Strings.isNotEmpty(request.getProjectFlavor())) { + return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); + } + + if (Strings.isNotEmpty(request.getConfigurationItem())) { + return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); + } + + throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + } + + private ClientAppProjectFlavorEntity resolveByFlavorName( + String flavorName, List flavors, String clientId) { + return flavors.stream() + .filter(f -> flavorName.equals(f.getName())) + .findFirst() + .orElseThrow(() -> { + log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); + return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + }); + } + + private ClientAppProjectFlavorEntity resolveByConfigurationItem( + String configurationItem, List flavors, String clientId) { + + List matchingFlavors = flavors.stream() + .filter(f -> configurationItem.equals(f.getConfigItem())) + .toList(); + + if (matchingFlavors.size() != 1) { + log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", + configurationItem, clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); + if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { + log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", + configurationItem, matchedFlavor.getName(), clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + return matchedFlavor; + } + + private void resolveRequestDefaults(CreateProjectRequest request, ClientAppProjectFlavorEntity flavor) { + if (Strings.isEmpty(request.getProjectFlavor())) { + request.setProjectFlavor(flavor.getName()); + } + if (Strings.isEmpty(request.getConfigurationItem())) { + request.setConfigurationItem(flavor.getConfigItem()); + } + if (Strings.isEmpty(request.getOwner())) { + request.setOwner(flavor.getProjectOwner()); + } + if (Strings.isEmpty(request.getLocation())) { + request.setLocation(flavor.getLocation()); + } + } + + private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) + throws ProjectKeyGenerationException { + if (Strings.isNotEmpty(existingProjectKey)) { + validateProjectNotExists(existingProjectKey); + return existingProjectKey; + } + + String pattern = flavor.getProjectKeyPattern(); + try { + String generatedProjectKey = generateProjectKeyService.generateProjectKey(pattern); + validateProjectNotExists(generatedProjectKey); + return generatedProjectKey; + } catch (org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException e) { + throw new ProjectKeyGenerationException("Failed to generate unique project key", e); + } + } + + private void validateProjectNotExists(String projectKey) { + if (projectService.getProject(projectKey) != null) { + throw new ProjectValidationException(ErrorKey.DUPLICATE_RECORD); + } } @Override public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } + + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { + String[] allowedConfigItems = flavor.getAllowedConfigItems(); + if (allowedConfigItems == null || allowedConfigItems.length == 0) { + return true; + } + return Arrays.asList(allowedConfigItems).contains(configurationItem); + } } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java new file mode 100644 index 0000000..dffa620 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.project.service; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClientAppService { + + private final ClientAppRepository clientAppRepository; + + @Transactional(readOnly = true) + public ClientAppEntity findByClientId(UUID clientId) { + String clientIdStr = clientId.toString(); + log.debug("Looking up ClientApp for clientId={}", clientIdStr); + return clientAppRepository.findByClientId(clientIdStr) + .orElseThrow(() -> { + log.warn("ClientApp not found for clientId={}", clientIdStr); + return new ClientAppNotRegisteredException(clientIdStr); + }); + } +} \ No newline at end of file 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 index 65ea765..93f666c 100644 --- 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 @@ -16,6 +16,8 @@ public void validate(CreateProjectRequest request) { validateProjectName(request.getProjectName()); validateProjectDescription(request.getProjectDescription()); validateFlavorOrConfigItem(request); + validateOwner(request.getOwner()); + validateX2OdsAccount(request.getX2OdsAccount()); } private void validateProjectKey(String projectKey) { @@ -49,4 +51,31 @@ private void validateFlavorOrConfigItem(CreateProjectRequest request) { ); } } + + + private void validateX2OdsAccount(String x2OdsAccount) { + if (!isValidX2OdsAccount(x2OdsAccount)) { + throw new ProjectValidationException(ErrorKey.PROJECT_X2ACCOUNT_INVALID_FORMAT); + } + } + + private void validateOwner(String owner) { + if (!isValidOwner(owner)) { + throw new ProjectValidationException(ErrorKey.PROJECT_OWNER_INVALID_FORMAT); + } + } + + private boolean isValidOwner(String owner) { + if (owner == null || owner.isBlank()) { + return true; + } + return owner.matches("^[a-z]{1,10}$"); + } + + private boolean isValidX2OdsAccount(String x2OdsAccount) { + if (x2OdsAccount == null || x2OdsAccount.isBlank()) { + return true; + } + return x2OdsAccount.matches("^x2[a-zA-Z0-9]{0,13}$"); + } } 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 f40526b..e10a0bc 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 @@ -20,6 +20,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; + class ProjectControllerTest { @Mock @@ -52,17 +54,17 @@ void create_project_returns_ok_when_creation_succeeds() throws Exception { serviceResponse.setStatus("Initiated"); serviceResponse.setMessage("The project creation process has been successfully initiated."); - when(projectsFacade.createProject(any(CreateProjectRequest.class))) + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) .thenReturn(serviceResponse); ResponseEntity result = sut.createProject(request); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); assertThat(result.getBody().getError()).isNull(); - assertThat(result.getBody().getErrorKey()).isNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); verify(projectRequestValidator).validate(request); } @@ -72,7 +74,7 @@ void create_project_returns_conflict_when_project_creation_exception_is_thrown() CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("EXISTING"); - when(projectsFacade.createProject(any(CreateProjectRequest.class))) + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); ResponseEntity result = sut.createProject(request); @@ -92,7 +94,7 @@ void create_project_returns_conflict_when_project_creation_exception_is_thrown() 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))) + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); ResponseEntity result = sut.createProject(request); 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 89ff398..3addab8 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 @@ -1,95 +1,507 @@ package org.opendevstack.apiservice.project.facade.impl; +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.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; -import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) class ProjectsFacadeImplTest { + private static final UUID CLIENT_ID = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + private static final UUID PROJECT_ID = UUID.fromString("aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee"); + @Mock private ProjectService projectService; + @Mock + private ClientAppService clientAppService; + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private AutomationPlatformService automationPlatformService; + private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); private ProjectsFacadeImpl sut; + private AutoCloseable mocks; @BeforeEach - void setup() { - sut = new ProjectsFacadeImpl(projectService, projectMapper); + void setUp() throws Exception { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl(projectService, projectMapper, clientAppService, + generateProjectKeyService, automationPlatformService); + + Field workflowField = ProjectsFacadeImpl.class.getDeclaredField("createProjectWorkflow"); + workflowField.setAccessible(true); + workflowField.set(sut, "create-project-workflow"); } + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + @Test - void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey("PROJ01"); + void create_project_returns_response_when_resolved_by_flavor_name() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "defaultOwner"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.PENDING); + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(serviceResponse); + assertNotNull(result); + assertEquals(Status.PENDING.getDbValue(), result.getStatus()); + assertEquals("DLSS", result.getProjectFlavor()); + verify(clientAppService).findByClientId(CLIENT_ID); + verify(projectService).createProject(any(ProjectRequest.class)); + verify(automationPlatformService).executeWorkflow(anyString(), anyMap()); + } - CreateProjectResponse response = sut.createProject(request); + @Test + void create_project_returns_response_when_resolved_by_config_item() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "defaultOwner"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "DLSS01"); - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Pending"); - verify(projectService).createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class)); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertNotNull(result); + assertEquals("DLSS", result.getProjectFlavor()); + verify(projectService).createProject(any(ProjectRequest.class)); } @Test - void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(null); + void create_project_fills_owner_from_flavor_when_not_provided() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "flavorOwner"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); + request.setOwner(null); // not provided - CreateProjectResponse response = sut.createProject(request); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); - assertThat(response).isNull(); + sut.createProject(request, CLIENT_ID); + + assertEquals("flavorOwner", request.getOwner()); } @Test - void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.RUNNING); + void create_project_fills_location_from_flavor_when_not_provided() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "us", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); + request.setLocation(null); - when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + sut.createProject(request, CLIENT_ID); + + assertEquals("us", request.getLocation()); + } + + @Test + void create_project_fills_config_item_from_flavor_when_resolved_by_flavor_name() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-DEFAULT", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); - CreateProjectResponse response = sut.getProject("PROJ01"); + sut.createProject(request, CLIENT_ID); - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Running"); + assertEquals("CI-DEFAULT", request.getConfigurationItem()); } @Test - void getProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + void create_project_fills_flavor_name_from_flavor_when_resolved_by_config_item() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "DLSS01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + sut.createProject(request, CLIENT_ID); + + assertEquals("DLSS", request.getProjectFlavor()); + } + + @Test + void create_project_does_not_override_owner_when_already_provided() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "flavorOwner"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); + request.setOwner("customOwner"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("DLSS01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + sut.createProject(request, CLIENT_ID); + + assertEquals("customOwner", request.getOwner()); + } + + @Test + void create_project_uses_existing_project_key_when_provided() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "MY_KEY"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("MY_KEY")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("MY_KEY", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + sut.createProject(request, CLIENT_ID); + + assertEquals("MY_KEY", request.getProjectKey()); + verify(generateProjectKeyService, never()).generateProjectKey(anyString()); + } + + @Test + void create_project_generates_project_key_using_flavor_pattern_when_not_provided() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, null); // no project key + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); + when(projectService.getProject("DLSS000001")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS000001", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + sut.createProject(request, CLIENT_ID); + + assertEquals("DLSS000001", request.getProjectKey()); + verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); + } + + @Test + void create_project_throws_validation_exception_when_existing_project_key_already_used() { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "EXISTING"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("EXISTING")).thenReturn(buildProjectResponse("EXISTING", Status.RUNNING)); + + ProjectValidationException ex = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + assertEquals(ErrorKey.DUPLICATE_RECORD, ex.getErrorKey()); + } + + @Test + void create_project_throws_key_generation_exception_when_service_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, null); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(generateProjectKeyService.generateProjectKey("DLSS%06d")) + .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); + + assertThrows( + ProjectKeyGenerationException.class, + () -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void create_project_throws_client_app_not_registered_exception_when_client_does_not_exist() { + CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)) + .thenThrow(new ClientAppNotRegisteredException(CLIENT_ID.toString())); + + ClientAppNotRegisteredException exception = assertThrows( + ClientAppNotRegisteredException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertNotNull(exception); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_throws_validation_exception_when_flavor_is_not_configured_for_client() { + ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("AMP", "CI-002", new String[]{}, "eu", "o"))); + CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.INVALID_PROJECT_FLAVOR, exception.getErrorKey()); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_throws_validation_exception_when_client_has_no_flavors() { + ClientAppEntity clientApp = buildClientApp(Collections.emptyList()); + CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.INVALID_PROJECT_FLAVOR, exception.getErrorKey()); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_throws_validation_exception_when_neither_flavor_nor_config_item_provided() { + ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "o"))); + CreateProjectRequest request = buildFullRequest(null, null, "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @Test + void create_project_throws_validation_exception_when_config_item_does_not_match_any_flavor() { + ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("DLSS", "CI-999", new String[]{}, "eu", "o"))); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_throws_validation_exception_when_config_item_matches_multiple_flavors() { + ClientAppEntity clientApp = buildClientApp(List.of( + buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "o"), + buildFlavor("AMP", "CI-001", new String[]{}, "eu", "o"))); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_succeeds_when_config_item_matches_one_flavor_and_allowed_list_is_empty() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("KEY01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + assertDoesNotThrow(() -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void create_project_succeeds_when_config_item_is_present_in_allowed_list() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{"CI-001", "CI-002"}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("KEY01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job1", "ok")); + + assertDoesNotThrow(() -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void create_project_throws_validation_exception_when_config_item_is_not_in_allowed_list() { + ClientAppEntity clientApp = buildClientApp( + List.of(buildFlavor("DLSS", "CI-001", new String[]{"CI-002", "CI-003"}, "eu", "o"))); + CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.createProject(request, CLIENT_ID)); + + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); + verify(clientAppService).findByClientId(CLIENT_ID); + } + + @Test + void create_project_throws_automation_exception_when_automation_platform_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("KEY01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenThrow(new AutomationPlatformException("connection error")); + + assertThrows( + AutomationPlatformException.class, + () -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void create_project_throws_automation_exception_when_automation_result_is_not_successful() throws Exception { + ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); + ClientAppEntity clientApp = buildClientApp(List.of(flavor)); + CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectService.getProject("KEY01")).thenReturn(null); + when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.failure("job1", "nope", "some error")); + + assertThrows( + AutomationPlatformException.class, + () -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void get_project_returns_mapped_response_when_service_returns_value() { + ProjectResponse serviceResponse = buildProjectResponse("PROJ01", Status.RUNNING); + when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + + CreateProjectResponse result = sut.getProject("PROJ01"); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Running", result.getStatus()); + verify(projectService).getProject("PROJ01"); + } + + @Test + void get_project_returns_null_when_service_returns_null() { when(projectService.getProject("UNKNOWN")).thenReturn(null); + + CreateProjectResponse result = sut.getProject("UNKNOWN"); + + assertNull(result); + verify(projectService).getProject("UNKNOWN"); + } + + private CreateProjectRequest buildFullRequest(String projectFlavor, String configurationItem, String projectKey) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey(projectKey); + request.setProjectFlavor(projectFlavor); + request.setConfigurationItem(configurationItem); + request.setProjectName("Test Project"); + request.setProjectDescription("A test project"); + request.setOwner("testowner"); + request.setLocation("eu"); + request.setX2OdsAccount("x2test"); + return request; + } - CreateProjectResponse response = sut.getProject("UNKNOWN"); + private ProjectResponse buildProjectResponse(String projectKey, Status status) { + return ProjectResponse.builder() + .projectId(PROJECT_ID) + .projectKey(projectKey) + .status(status) + .build(); + } + + private ClientAppEntity buildClientApp(List flavors) { + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(CLIENT_ID.toString()) + .clientName("Test App") + .build(); + entity.setProjectFlavors(flavors); + return entity; + } - assertThat(response).isNull(); + private ClientAppProjectFlavorEntity buildFlavor( + String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { + return ClientAppProjectFlavorEntity.builder() + .name(name) + .configItem(configItem) + .allowedConfigItems(allowedConfigItems) + .projectKeyPattern(name + "%06d") + .location(location) + .projectOwner(projectOwner) + .build(); } -} +} \ No newline at end of file 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 76419de..7ecbd89 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 @@ -28,14 +28,12 @@ void setUp() { void to_service_request_maps_all_fields_correctly() { CreateProjectRequest apiRequest = new CreateProjectRequest(); apiRequest.setProjectKey("PROJ01"); - apiRequest.setProjectKeyPattern("SS%06d"); apiRequest.setProjectDescription("A test project"); ProjectRequest result = projectMapper.toServiceRequest(apiRequest); assertNotNull(result); assertEquals("PROJ01", result.getProjectKey()); - assertEquals("SS%06d", result.getProjectKeyPattern()); assertEquals("A test project", result.getProjectDescription()); } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java new file mode 100644 index 0000000..f1bfe4e --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -0,0 +1,56 @@ +package org.opendevstack.apiservice.project.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +class ClientAppServiceTest { + + @Mock + private ClientAppRepository clientAppRepository; + private ClientAppService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ClientAppService(clientAppRepository); + } + + @Test + void findByClientId_returns_entity_when_client_exists() { + + UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(clientId.toString()) + .clientName("Test App") + .build(); + when(clientAppRepository.findByClientId(clientId.toString())).thenReturn(Optional.of(entity)); + + ClientAppEntity result = sut.findByClientId(clientId); + + assertThat(result).isNotNull(); + assertThat(result.getClientId()).isEqualTo(clientId.toString()); + assertThat(result.getClientName()).isEqualTo("Test App"); + verify(clientAppRepository).findByClientId(clientId.toString()); + } + + @Test + void findByClientId_throws_exception_when_client_not_found() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(clientAppRepository.findByClientId(clientId.toString())).thenReturn(Optional.empty()); + + assertThrows( + ClientAppNotRegisteredException.class, + () -> sut.findByClientId(clientId)); + verify(clientAppRepository).findByClientId(clientId.toString()); + } +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java new file mode 100644 index 0000000..1c2aef3 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -0,0 +1,104 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA entity mapping the {@code client_apps} table. + * + *

Represents a registered Azure AD client that is authorised to call the + * service APIs. Each client may have zero or more + * {@link ClientAppProjectFlavorEntity project flavors} that control how + * projects are created.

+ * + *

Schema is managed externally via Liquibase + * ({@code 002_create_client_apps_table.sql}). Hibernate is configured with + * {@code ddl-auto=validate}.

+ */ +@Entity +@Table(name = "client_apps") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = "projectFlavors") +public class ClientAppEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Azure AD Application (client) UUID. Unique index {@code uq_client_apps_client_id}. */ + @Column(name = "client_id", nullable = false, unique = true, length = 36) + private String clientId; + + /** Azure AD application display name. */ + @Column(name = "client_name", length = 255) + private String clientName; + + /** + * Granted permissions. Known values: {@code project:add}, {@code project:detail}, + * {@code project:list}. + */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "permissions", nullable = false, columnDefinition = "TEXT[]") + @Builder.Default + private String[] permissions = {}; + + /** OAuth2 scope or role granted to this client (e.g. {@code api.read}, {@code api.write}). */ + @Column(name = "role_scope") + private String roleScope; + + /** When {@code false} the client is denied access without removing the row. */ + @Column(name = "enabled", nullable = false) + @Builder.Default + private boolean enabled = true; + + /** Project flavor configurations associated with this client. */ + @OneToMany(mappedBy = "clientApp", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List projectFlavors = new ArrayList<>(); + + /** Original creation timestamp (UTC). Set automatically on first persist. */ + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime createdAt; + + /** Timestamp of last update (UTC). Updated automatically on every merge. */ + @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime updatedAt; + + @PrePersist + void onPrePersist() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onPreUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java new file mode 100644 index 0000000..17854e8 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java @@ -0,0 +1,110 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA entity mapping the {@code client_app_project_flavors} table. + * + *

Each row represents a project-flavor configuration tied to a specific + * {@link ClientAppEntity}. The flavor controls how projects are created + * (key pattern, template, owner, etc.).

+ * + *

Schema is managed externally via Liquibase + * ({@code 002_create_client_apps_table.sql}). Hibernate is configured with + * {@code ddl-auto=validate}.

+ */ +@Entity +@Table(name = "client_app_project_flavors") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = "clientApp") +public class ClientAppProjectFlavorEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Owning client application. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_app_id", nullable = false) + private ClientAppEntity clientApp; + + /** Flavor name (e.g. {@code DLSS}, {@code AMP}). */ + @Column(name = "name", nullable = false, length = 50) + private String name; + + /** Printf-style pattern used to generate the project key (e.g. {@code DLSS%06d}). */ + @Column(name = "project_key_pattern", nullable = false, length = 100) + private String projectKeyPattern; + + /** ODS / Jira template identifier. */ + @Column(name = "template_id") + private Integer templateId; + + /** Default project owner username. */ + @Column(name = "project_owner", length = 255) + private String projectOwner; + + /** Service account associated with the flavor. */ + @Column(name = "service_account", length = 255) + private String serviceAccount; + + /** Default CMDB configuration item for projects created under this flavor. */ + @Column(name = "config_item", length = 255) + private String configItem; + + /** Allowed CMDB configuration item overrides (empty = no overrides permitted). */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "allowed_config_items", nullable = false, columnDefinition = "TEXT[]") + @Builder.Default + private String[] allowedConfigItems = {}; + + /** Deployment region (e.g. {@code eu}, {@code us}). */ + @Column(name = "location", length = 50) + private String location; + + /** Original creation timestamp (UTC). Set automatically on first persist. */ + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime createdAt; + + /** Timestamp of last update (UTC). Updated automatically on every merge. */ + @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime updatedAt; + + @PrePersist + void onPrePersist() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onPreUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java new file mode 100644 index 0000000..1c4753d --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.persistence.repository; + +import java.util.Optional; +import java.util.UUID; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ClientAppRepository extends JpaRepository { + + @EntityGraph(attributePaths = "projectFlavors") + Optional findByClientId(String clientId); + + boolean existsByClientId(String clientId); + +} + diff --git a/service-projects/pom.xml b/service-projects/pom.xml index 2a82727..f391ffe 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -69,7 +69,13 @@ org.opendevstack.apiservice external-service-ocp ${project.version} -
+ + + + org.opendevstack.apiservice + external-service-aap + ${project.version} + org.projectlombok diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java index 18a6491..72060ad 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -12,6 +12,7 @@ public interface ProjectResponseMapper { @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "id", target = "projectId") ProjectResponse toCreateProjectResponse(ProjectEntity entity); @Named("mapStatus") diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index 186ffaa..a20476c 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -4,16 +4,30 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor public class ProjectRequest { - + private String projectKey; - - private String projectKeyPattern; - + private String projectName; - + private String projectDescription; + + private UUID projectId; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java index 40ee62a..6db5063 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -5,15 +5,25 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ProjectResponse { + private UUID projectId; + private String projectKey; private Status status; private String projectFlavor; + + private String message; + + private String error; + + private String errorKey; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 1d8d7b6..98370bc 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -1,16 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service.impl; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.stereotype.Service; @@ -18,24 +14,33 @@ @Service @Slf4j -@AllArgsConstructor public class ProjectServiceImpl implements ProjectService { - - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; - - private final GenerateProjectKeyService generateProjectKeyService; private final ProjectRepository projectRepository; private final ProjectResponseMapper projectResponseMapper; + + public ProjectServiceImpl(ProjectRepository projectRepository, + ProjectResponseMapper projectResponseMapper) { + this.projectRepository = projectRepository; + this.projectResponseMapper = projectResponseMapper; + } @Override public ProjectResponse createProject(ProjectRequest request) { - return ProjectResponse.builder().build(); + ProjectEntity entity = new ProjectEntity(); + entity.setProjectKey(request.getProjectKey()); + entity.setProjectName(request.getProjectName()); + entity.setProjectFlavor(request.getProjectFlavor()); + entity.setConfigurationItem(request.getConfigurationItem()); + entity.setDescription(request.getProjectFlavor() + " project"); + entity.setLdapGroupManager(getLdapGroup("MANAGER", request.getProjectKey())); + entity.setLdapGroupTeam(getLdapGroup("TEAM", request.getProjectKey())); + entity.setLdapGroupStakeholder(getLdapGroup("STAKEHOLDER", request.getProjectKey())); + entity.setStatus(Status.PENDING.getDbValue()); + entity.setLocation(request.getLocation()); + ProjectEntity save = projectRepository.save(entity); + return projectResponseMapper.toCreateProjectResponse(save); } @Override @@ -48,5 +53,9 @@ public ProjectResponse getProject(String projectKey) { return null; } + + private String getLdapGroup(String role, String projectKey) { + return "CN=BI-AS-ATLASSIAN-P-" + projectKey + "-" + role + ",OU=BIDS-managed,DC=eu,DC=boehringer,DC=com"; + } } diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java index 827ba41..51adb00 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -4,16 +4,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; -import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import java.util.Optional; import java.util.UUID; @@ -29,18 +24,6 @@ class ProjectServiceImplTest { - @Mock - private OpenshiftService openshiftService; - - @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private GenerateProjectKeyService generateProjectKeyService; - @Mock private ProjectRepository projectRepository; @@ -53,10 +36,6 @@ class ProjectServiceImplTest { void setUp() { MockitoAnnotations.openMocks(this); projectService = new ProjectServiceImpl( - openshiftService, - bitbucketService, - jiraService, - generateProjectKeyService, projectRepository, projectResponseMapper ); @@ -89,10 +68,8 @@ void get_project_returns_response_when_project_exists() { when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(projectEntity)); when(projectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); - ProjectResponse result = projectService.getProject(projectKey); - assertNotNull(result); assertEquals(projectKey, result.getProjectKey()); @@ -107,33 +84,14 @@ void get_project_returns_null_when_project_does_not_exist() { String projectKey = "NON-EXISTING"; when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(projectKey); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); verify(projectResponseMapper, never()).toCreateProjectResponse(any()); } - @Test - void create_project_returns_empty_response() { - - ProjectRequest request = new ProjectRequest(); - request.setProjectKey("NEW-PROJECT"); - request.setProjectKeyPattern("NEW%06d"); - request.setProjectName("New Project"); - request.setProjectDescription("New test project"); - - - ProjectResponse result = projectService.createProject(request); - - - assertNotNull(result); - assertNull(result.getProjectKey()); - } - @Test void get_project_propagates_repository_exception() { @@ -150,10 +108,8 @@ void get_project_propagates_repository_exception() { void get_project_returns_null_when_project_key_is_null() { when(projectRepository.findByProjectKeyIgnoreCase(null)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(null); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(null); From cd32128a939caff1e673c0492e2a639cef1961f5 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Thu, 26 Mar 2026 13:39:01 +0100 Subject: [PATCH 05/14] Feature/refactor project creation exceptions and introduce command pattern for project creation --- .../project/controller/ProjectController.java | 59 +- .../advice/ProjectExceptionHandler.java | 50 ++ .../exception/ProjectCreationException.java | 2 +- .../ProjectKeyGenerationException.java | 2 +- .../project/facade/ProjectsFacade.java | 6 +- .../facade/impl/ProjectsFacadeImpl.java | 167 +----- .../project/mapper/ProjectMapper.java | 3 + .../controller/ProjectControllerTest.java | 68 +-- .../advice/ProjectExceptionHandlerTest.java | 89 ++- .../facade/impl/ProjectsFacadeImplTest.java | 533 ++++-------------- .../project/service/ClientAppServiceTest.java | 1 + .../AutomationPlatformException.java | 2 +- .../service/AutomationPlatformService.java | 4 +- 13 files changed, 291 insertions(+), 695 deletions(-) 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 49385d4..47b80d4 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 @@ -3,14 +3,10 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.api.ProjectsApi; -import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; 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.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -41,54 +37,27 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - try { - UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); - CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); - projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); - projectResponse.setErrorKey(ErrorKey.OK.getKey()); - projectResponse.setProjectKey(null); - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(projectResponse); - } catch (ProjectCreationException e) { - log.error("Project creation conflict: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.conflict(e.getMessage(), API_BASE_PATH)); - } catch (ProjectKeyGenerationException e) { - log.error("Failed to generate project key: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); - } catch (AutomationPlatformException e) { - log.error("Failed to execute automated job: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.internalError(API_BASE_PATH, e.getMessage())); - } + UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); + projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectResponse); } @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { String location = API_BASE_PATH + "/" + projectKey; - try { - CreateProjectResponse response = projectsFacade.getProject(projectKey); - if (response == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.notFound(projectKey, location)); - } - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, location) - .body(response); - } catch (Exception e) { - log.error("Unexpected error retrieving project '{}': {}", projectKey, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.internalError(location)); + .body(ProjectResponseFactory.notFound(projectKey, location)); } + return ResponseEntity.status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, location) + .body(response); } } 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 index cc31cc5..ef97712 100644 --- 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 @@ -1,9 +1,12 @@ package org.opendevstack.apiservice.project.controller.advice; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.springframework.http.HttpStatus; @@ -64,6 +67,53 @@ public ResponseEntity handleValidationException(ProjectVa return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + @ExceptionHandler(ProjectCreationException.class) + public ResponseEntity handleProjectCreationException( + ProjectCreationException ex) { + log.error("Project creation error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.PROJECT_ALREADY_EXISTS.getMessage()); + response.setErrorKey(ErrorKey.PROJECT_ALREADY_EXISTS.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + @ExceptionHandler(ProjectKeyGenerationException.class) + public ResponseEntity handleProjectKeyGenerationException( + ProjectKeyGenerationException ex) { + log.error("Failed to generate project key: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey("PROJECT_KEY_GENERATION_FAILED"); + response.setMessage("Failed to generate a unique project key."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(AutomationPlatformException.class) + public ResponseEntity handleAutomationPlatformException( + AutomationPlatformException ex) { + log.error("Failed to execute automated job: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).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/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java index 87e12af..8189cdc 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.project.exception; -public class ProjectCreationException extends Exception { +public class ProjectCreationException extends RuntimeException { public ProjectCreationException(String message) { super(message); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java index be9b66a..954a6bd 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.project.exception; -public class ProjectKeyGenerationException extends Exception { +public class ProjectKeyGenerationException extends RuntimeException { public ProjectKeyGenerationException(String message) { super(message); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index 408dee0..f8b0112 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,8 +1,5 @@ package org.opendevstack.apiservice.project.facade; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -10,8 +7,7 @@ public interface ProjectsFacade { - CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) - throws ProjectCreationException, ProjectKeyGenerationException, AutomationPlatformException; + CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); CreateProjectResponse getProject(String projectKey); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 197f2ea..b2047ff 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,32 +1,22 @@ package org.opendevstack.apiservice.project.facade.impl; import lombok.extern.slf4j.Slf4j; -import org.apache.logging.log4j.util.Strings; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; -import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; -import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; -import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.model.Status; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import java.util.UUID; @Component("apiProjectFacadeImpl") @@ -40,168 +30,59 @@ public class ProjectsFacadeImpl implements ProjectsFacade { private final ProjectMapper projectMapper; - private final ClientAppService clientAppService; + private final AutomationParametersMapper automationParametersMapper; + + private final ProjectCreationResponseMapper projectCreationResponseMapper; + + private final ProjectCreationCommandBuilder projectCreationCommandBuilder; - private final GenerateProjectKeyService generateProjectKeyService; + private final ClientAppService clientAppService; private final AutomationPlatformService automationPlatformService; public ProjectsFacadeImpl( ProjectService projectService, ProjectMapper projectMapper, + AutomationParametersMapper automationParametersMapper, + ProjectCreationResponseMapper projectCreationResponseMapper, + ProjectCreationCommandBuilder projectCreationCommandBuilder, ClientAppService clientAppService, - GenerateProjectKeyService generateProjectKeyService, AutomationPlatformService automationPlatformService) { this.projectService = projectService; this.projectMapper = projectMapper; + this.automationParametersMapper = automationParametersMapper; + this.projectCreationResponseMapper = projectCreationResponseMapper; + this.projectCreationCommandBuilder = projectCreationCommandBuilder; this.clientAppService = clientAppService; - this.generateProjectKeyService = generateProjectKeyService; this.automationPlatformService = automationPlatformService; } @Override - public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) - throws ProjectCreationException, ProjectKeyGenerationException, AutomationPlatformException { + public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) { ClientAppEntity clientApp = clientAppService.findByClientId(clientId); - ClientAppProjectFlavorEntity resolvedFlavor = resolveFlavor(request, clientApp); + ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp, clientId); - resolveRequestDefaults(request, resolvedFlavor); - - String resolvedProjectKey = resolveProjectKey(request.getProjectKey(), resolvedFlavor); - request.setProjectKey(resolvedProjectKey); - - ProjectResponse project = projectService.createProject(projectMapper.toServiceRequest(request)); - - Map parameters = new HashMap<>(); - parameters.put("geographic_region", request.getLocation()); - parameters.put("project_flavor", request.getProjectFlavor()); - parameters.put("project_owner", request.getOwner()); - parameters.put("project_id", project.getProjectId().toString()); - parameters.put("configuration_item", request.getConfigurationItem()); - parameters.put("project_key", request.getProjectKey()); - parameters.put("special_account", request.getX2OdsAccount()); - parameters.put("description", request.getProjectDescription()); - parameters.put("project_name", request.getProjectName()); - parameters.put("client_id", clientId.toString()); + ProjectResponse project = projectService.createProject(projectMapper.toServiceRequest(command)); AutomationExecutionResult automationExecutionResult = automationPlatformService - .executeWorkflow(createProjectWorkflow, parameters); + .executeWorkflow( + createProjectWorkflow, + automationParametersMapper.toWorkflowParameters( + command, + project.getProjectId().toString())); if (automationExecutionResult.isSuccessful()) { - CreateProjectResponse response = new CreateProjectResponse(); - response.setMessage("The project creation process has been successfully initiated."); - response.setStatus(Status.PENDING.getDbValue()); - response.setProjectFlavor(request.getProjectFlavor()); - response.setProjectKey(project.getProjectKey()); - return response; + return projectCreationResponseMapper.toSuccessResponse(command, project); } else { - throw new AutomationPlatformException("Failed to create project: " + throw new ProjectCreationException("Failed to create project: " + automationExecutionResult.getErrorDetails()); } } - - private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { - List flavors = clientApp.getProjectFlavors(); - if (flavors == null || flavors.isEmpty()) { - log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); - throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); - } - - if (Strings.isNotEmpty(request.getProjectFlavor())) { - return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); - } - - if (Strings.isNotEmpty(request.getConfigurationItem())) { - return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); - } - - throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); - } - - private ClientAppProjectFlavorEntity resolveByFlavorName( - String flavorName, List flavors, String clientId) { - return flavors.stream() - .filter(f -> flavorName.equals(f.getName())) - .findFirst() - .orElseThrow(() -> { - log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); - return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); - }); - } - - private ClientAppProjectFlavorEntity resolveByConfigurationItem( - String configurationItem, List flavors, String clientId) { - - List matchingFlavors = flavors.stream() - .filter(f -> configurationItem.equals(f.getConfigItem())) - .toList(); - - if (matchingFlavors.size() != 1) { - log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", - configurationItem, clientId); - throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); - } - - ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); - if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { - log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", - configurationItem, matchedFlavor.getName(), clientId); - throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); - } - - return matchedFlavor; - } - - private void resolveRequestDefaults(CreateProjectRequest request, ClientAppProjectFlavorEntity flavor) { - if (Strings.isEmpty(request.getProjectFlavor())) { - request.setProjectFlavor(flavor.getName()); - } - if (Strings.isEmpty(request.getConfigurationItem())) { - request.setConfigurationItem(flavor.getConfigItem()); - } - if (Strings.isEmpty(request.getOwner())) { - request.setOwner(flavor.getProjectOwner()); - } - if (Strings.isEmpty(request.getLocation())) { - request.setLocation(flavor.getLocation()); - } - } - - private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) - throws ProjectKeyGenerationException { - if (Strings.isNotEmpty(existingProjectKey)) { - validateProjectNotExists(existingProjectKey); - return existingProjectKey; - } - - String pattern = flavor.getProjectKeyPattern(); - try { - String generatedProjectKey = generateProjectKeyService.generateProjectKey(pattern); - validateProjectNotExists(generatedProjectKey); - return generatedProjectKey; - } catch (org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException e) { - throw new ProjectKeyGenerationException("Failed to generate unique project key", e); - } - } - - private void validateProjectNotExists(String projectKey) { - if (projectService.getProject(projectKey) != null) { - throw new ProjectValidationException(ErrorKey.DUPLICATE_RECORD); - } - } @Override public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } - - private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { - String[] allowedConfigItems = flavor.getAllowedConfigItems(); - if (allowedConfigItems == null || allowedConfigItems.length == 0) { - return true; - } - return Arrays.asList(allowedConfigItems).contains(configurationItem); - } } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java index 181b571..adb2032 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; @@ -16,6 +17,8 @@ public interface ProjectMapper { ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + ProjectRequest toServiceRequest(ProjectCreationCommand command); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") 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 e10a0bc..fbd1123 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 @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; 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; @@ -16,7 +14,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,13 +42,14 @@ void tearDown() throws Exception { } @Test - void create_project_returns_ok_when_creation_succeeds() throws Exception { + void create_project_returns_ok_when_creation_succeeds() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); CreateProjectResponse serviceResponse = new CreateProjectResponse(); serviceResponse.setProjectKey("PROJ01"); serviceResponse.setStatus("Initiated"); + serviceResponse.setErrorKey("000"); serviceResponse.setMessage("The project creation process has been successfully initiated."); when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) @@ -61,53 +59,13 @@ void create_project_returns_ok_when_creation_succeeds() throws Exception { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); assertThat(result.getBody().getError()).isNull(); assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); verify(projectRequestValidator).validate(request); - } - - @Test - 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), any(UUID.class))) - .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Project already exists"); - assertThat(result.getBody().getErrorKey()).isEqualTo("025"); - assertThat(result.getBody().getMessage()).contains("Project with key 'EXISTING' already exists"); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); - } - - @Test - 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), any(UUID.class))) - .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED"); - assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); + verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); } @Test @@ -142,23 +100,7 @@ void get_project_returns_not_found_when_project_does_not_exist() { assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); - } - - @Test - void get_project_returns_internal_server_error_when_service_throws_exception() { - when(projectsFacade.getProject(anyString())) - .thenThrow(new RuntimeException("Database error")); - - ResponseEntity result = sut.getProject("PROJ01"); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("003"); - assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectsFacade).getProject("UNKNOWN"); } } 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 035a7aa..2c242cb 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 @@ -4,8 +4,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -119,5 +123,88 @@ void handle_method_argument_not_valid_exception_returns_bad_request_response_for assertNull(result.getBody().getStatus()); assertNull(result.getBody().getErrorDescription()); } -} + @Test + void handle_project_creation_exception_returns_conflict() { + ProjectCreationException exception = new ProjectCreationException("Project with key 'EXISTING' already exists"); + + ResponseEntity result = sut.handleProjectCreationException(exception); + + assertEquals(HttpStatus.CONFLICT, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Project already exists", result.getBody().getError()); + assertEquals("025", result.getBody().getErrorKey()); + assertEquals("Project with key 'EXISTING' already exists", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_project_key_generation_exception_returns_internal_server_error() { + ProjectKeyGenerationException exception = new ProjectKeyGenerationException( + "Failed to generate unique project key after 10 retries"); + + ResponseEntity result = sut.handleProjectKeyGenerationException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("PROJECT_KEY_GENERATION_FAILED", result.getBody().getErrorKey()); + assertEquals("Failed to generate a unique project key.", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_client_app_not_registered_exception_returns_forbidden() { + ClientAppNotRegisteredException exception = new ClientAppNotRegisteredException("client-123"); + + ResponseEntity result = sut.handleClientAppNotRegisteredException(exception); + + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), result.getBody().getError()); + assertEquals("027", result.getBody().getErrorKey()); + assertEquals("ClientApp not registered, manual registration required", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_automation_platform_exception_returns_internal_server_error() { + AutomationPlatformException exception = new AutomationPlatformException("AAP job failed"); + + ResponseEntity result = sut.handleAutomationPlatformException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("AAP job failed", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("Database error"); + + ResponseEntity result = sut.handleGenericException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("An error occurred while processing the request.", 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 3addab8..afb35e2 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 @@ -1,20 +1,28 @@ package org.opendevstack.apiservice.project.facade.impl; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mapstruct.factory.Mappers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; -import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; -import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; -import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; -import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -22,486 +30,145 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - class ProjectsFacadeImplTest { private static final UUID CLIENT_ID = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); - private static final UUID PROJECT_ID = UUID.fromString("aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee"); @Mock private ProjectService projectService; - + @Mock - private ClientAppService clientAppService; + private ProjectMapper projectMapper; @Mock - private GenerateProjectKeyService generateProjectKeyService; - + private AutomationParametersMapper automationParametersMapper; + + @Mock + private ProjectCreationResponseMapper projectCreationResponseMapper; + + @Mock + private ProjectCreationCommandBuilder projectCreationCommandBuilder; + + @Mock + private ClientAppService clientAppService; + @Mock private AutomationPlatformService automationPlatformService; - private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); - private ProjectsFacadeImpl sut; private AutoCloseable mocks; @BeforeEach - void setUp() throws Exception { + void set_up() throws Exception { mocks = MockitoAnnotations.openMocks(this); - sut = new ProjectsFacadeImpl(projectService, projectMapper, clientAppService, - generateProjectKeyService, automationPlatformService); - + sut = new ProjectsFacadeImpl( + projectService, + projectMapper, + automationParametersMapper, + projectCreationResponseMapper, + projectCreationCommandBuilder, + clientAppService, + automationPlatformService); + Field workflowField = ProjectsFacadeImpl.class.getDeclaredField("createProjectWorkflow"); workflowField.setAccessible(true); workflowField.set(sut, "create-project-workflow"); } @AfterEach - void tearDown() throws Exception { + void tear_down() throws Exception { mocks.close(); } - - @Test - void create_project_returns_response_when_resolved_by_flavor_name() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "defaultOwner"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - CreateProjectResponse result = sut.createProject(request, CLIENT_ID); - - assertNotNull(result); - assertEquals(Status.PENDING.getDbValue(), result.getStatus()); - assertEquals("DLSS", result.getProjectFlavor()); - verify(clientAppService).findByClientId(CLIENT_ID); - verify(projectService).createProject(any(ProjectRequest.class)); - verify(automationPlatformService).executeWorkflow(anyString(), anyMap()); - } @Test - void create_project_returns_response_when_resolved_by_config_item() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "defaultOwner"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "DLSS01"); + void create_project_returns_success_response_when_automation_is_successful() { + CreateProjectRequest request = new CreateProjectRequest(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID.toString()).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Pending"); + apiResponse.setProjectFlavor("DLSS"); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); + when(projectCreationCommandBuilder.build(request, clientApp, CLIENT_ID)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.createProject(serviceRequest)).thenReturn(projectResponse); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of("project_key", "DLSS01")); when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); + .thenReturn(AutomationExecutionResult.success("job-1", "ok")); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); CreateProjectResponse result = sut.createProject(request, CLIENT_ID); - assertNotNull(result); + assertEquals("Pending", result.getStatus()); assertEquals("DLSS", result.getProjectFlavor()); - verify(projectService).createProject(any(ProjectRequest.class)); + verify(projectCreationCommandBuilder).build(request, clientApp, CLIENT_ID); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); } @Test - void create_project_fills_owner_from_flavor_when_not_provided() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "flavorOwner"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); - request.setOwner(null); // not provided - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("flavorOwner", request.getOwner()); - } - - @Test - void create_project_fills_location_from_flavor_when_not_provided() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "us", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); - request.setLocation(null); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("us", request.getLocation()); - } - - @Test - void create_project_fills_config_item_from_flavor_when_resolved_by_flavor_name() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-DEFAULT", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("CI-DEFAULT", request.getConfigurationItem()); - } - - @Test - void create_project_fills_flavor_name_from_flavor_when_resolved_by_config_item() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "DLSS01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("DLSS", request.getProjectFlavor()); - } - - @Test - void create_project_does_not_override_owner_when_already_provided() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "flavorOwner"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "DLSS01"); - request.setOwner("customOwner"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("DLSS01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("customOwner", request.getOwner()); - } - - @Test - void create_project_uses_existing_project_key_when_provided() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "MY_KEY"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("MY_KEY")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("MY_KEY", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("MY_KEY", request.getProjectKey()); - verify(generateProjectKeyService, never()).generateProjectKey(anyString()); - } - - @Test - void create_project_generates_project_key_using_flavor_pattern_when_not_provided() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, null); // no project key - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); - when(projectService.getProject("DLSS000001")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("DLSS000001", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - sut.createProject(request, CLIENT_ID); - - assertEquals("DLSS000001", request.getProjectKey()); - verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); - } - - @Test - void create_project_throws_validation_exception_when_existing_project_key_already_used() { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "EXISTING"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("EXISTING")).thenReturn(buildProjectResponse("EXISTING", Status.RUNNING)); - - ProjectValidationException ex = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - assertEquals(ErrorKey.DUPLICATE_RECORD, ex.getErrorKey()); - } - - @Test - void create_project_throws_key_generation_exception_when_service_fails() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, null); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(generateProjectKeyService.generateProjectKey("DLSS%06d")) - .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); - - assertThrows( - ProjectKeyGenerationException.class, - () -> sut.createProject(request, CLIENT_ID)); - } - - @Test - void create_project_throws_client_app_not_registered_exception_when_client_does_not_exist() { - CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)) - .thenThrow(new ClientAppNotRegisteredException(CLIENT_ID.toString())); - - ClientAppNotRegisteredException exception = assertThrows( - ClientAppNotRegisteredException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertNotNull(exception); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_throws_validation_exception_when_flavor_is_not_configured_for_client() { - ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("AMP", "CI-002", new String[]{}, "eu", "o"))); - CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.INVALID_PROJECT_FLAVOR, exception.getErrorKey()); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_throws_validation_exception_when_client_has_no_flavors() { - ClientAppEntity clientApp = buildClientApp(Collections.emptyList()); - CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.INVALID_PROJECT_FLAVOR, exception.getErrorKey()); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_throws_validation_exception_when_neither_flavor_nor_config_item_provided() { - ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "o"))); - CreateProjectRequest request = buildFullRequest(null, null, "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); - } - - @Test - void create_project_throws_validation_exception_when_config_item_does_not_match_any_flavor() { - ClientAppEntity clientApp = buildClientApp(List.of(buildFlavor("DLSS", "CI-999", new String[]{}, "eu", "o"))); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_throws_validation_exception_when_config_item_matches_multiple_flavors() { - ClientAppEntity clientApp = buildClientApp(List.of( - buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "o"), - buildFlavor("AMP", "CI-001", new String[]{}, "eu", "o"))); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_succeeds_when_config_item_matches_one_flavor_and_allowed_list_is_empty() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("KEY01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - assertDoesNotThrow(() -> sut.createProject(request, CLIENT_ID)); - } - - @Test - void create_project_succeeds_when_config_item_is_present_in_allowed_list() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{"CI-001", "CI-002"}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("KEY01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.success("job1", "ok")); - - assertDoesNotThrow(() -> sut.createProject(request, CLIENT_ID)); - } - - @Test - void create_project_throws_validation_exception_when_config_item_is_not_in_allowed_list() { - ClientAppEntity clientApp = buildClientApp( - List.of(buildFlavor("DLSS", "CI-001", new String[]{"CI-002", "CI-003"}, "eu", "o"))); - CreateProjectRequest request = buildFullRequest(null, "CI-001", "KEY01"); - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - - ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.createProject(request, CLIENT_ID)); - - assertEquals(ErrorKey.INVALID_CONFIG_ITEM, exception.getErrorKey()); - verify(clientAppService).findByClientId(CLIENT_ID); - } - - @Test - void create_project_throws_automation_exception_when_automation_platform_fails() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("KEY01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); + void create_project_throws_project_creation_exception_when_automation_is_not_successful() { + CreateProjectRequest request = new CreateProjectRequest(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID.toString()).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp, CLIENT_ID)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); + when(projectService.createProject(any(ProjectRequest.class))) + .thenReturn(ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build()); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of()); when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenThrow(new AutomationPlatformException("connection error")); + .thenReturn(AutomationExecutionResult.failure("job-1", "error", "workflow failed")); - assertThrows( - AutomationPlatformException.class, - () -> sut.createProject(request, CLIENT_ID)); + assertThrows(ProjectCreationException.class, () -> sut.createProject(request, CLIENT_ID)); } @Test - void create_project_throws_automation_exception_when_automation_result_is_not_successful() throws Exception { - ClientAppProjectFlavorEntity flavor = buildFlavor("DLSS", "CI-001", new String[]{}, "eu", "owner1"); - ClientAppEntity clientApp = buildClientApp(List.of(flavor)); - CreateProjectRequest request = buildFullRequest("DLSS", null, "KEY01"); - - when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectService.getProject("KEY01")).thenReturn(null); - when(projectService.createProject(any(ProjectRequest.class))).thenReturn(buildProjectResponse("KEY01", Status.PENDING)); - when(automationPlatformService.executeWorkflow(anyString(), anyMap())) - .thenReturn(AutomationExecutionResult.failure("job1", "nope", "some error")); - - assertThrows( - AutomationPlatformException.class, - () -> sut.createProject(request, CLIENT_ID)); - } + void get_project_returns_mapped_response_when_service_returns_project() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse mappedResponse = new CreateProjectResponse(); + mappedResponse.setProjectKey("PROJ01"); + mappedResponse.setStatus("Running"); - @Test - void get_project_returns_mapped_response_when_service_returns_value() { - ProjectResponse serviceResponse = buildProjectResponse("PROJ01", Status.RUNNING); when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); - + when(projectMapper.toApiResponse(serviceResponse)).thenReturn(mappedResponse); + CreateProjectResponse result = sut.getProject("PROJ01"); - - assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); assertEquals("Running", result.getStatus()); - verify(projectService).getProject("PROJ01"); } @Test void get_project_returns_null_when_service_returns_null() { when(projectService.getProject("UNKNOWN")).thenReturn(null); - - CreateProjectResponse result = sut.getProject("UNKNOWN"); - - assertNull(result); - verify(projectService).getProject("UNKNOWN"); - } - - private CreateProjectRequest buildFullRequest(String projectFlavor, String configurationItem, String projectKey) { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey(projectKey); - request.setProjectFlavor(projectFlavor); - request.setConfigurationItem(configurationItem); - request.setProjectName("Test Project"); - request.setProjectDescription("A test project"); - request.setOwner("testowner"); - request.setLocation("eu"); - request.setX2OdsAccount("x2test"); - return request; - } - - private ProjectResponse buildProjectResponse(String projectKey, Status status) { - return ProjectResponse.builder() - .projectId(PROJECT_ID) - .projectKey(projectKey) - .status(status) - .build(); - } + when(projectMapper.toApiResponse(null)).thenReturn(null); - private ClientAppEntity buildClientApp(List flavors) { - ClientAppEntity entity = ClientAppEntity.builder() - .clientId(CLIENT_ID.toString()) - .clientName("Test App") - .build(); - entity.setProjectFlavors(flavors); - return entity; - } + CreateProjectResponse result = sut.getProject("UNKNOWN"); - private ClientAppProjectFlavorEntity buildFlavor( - String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { - return ClientAppProjectFlavorEntity.builder() - .name(name) - .configItem(configItem) - .allowedConfigItems(allowedConfigItems) - .projectKeyPattern(name + "%06d") - .location(location) - .projectOwner(projectOwner) - .build(); + assertNull(result); } -} \ No newline at end of file +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java index f1bfe4e..ac87319 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -12,6 +12,7 @@ import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; + class ClientAppServiceTest { @Mock diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java index 2c7686f..6a0805b 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java @@ -3,7 +3,7 @@ /** * Exception thrown when there are issues with automation platform operations. */ -public class AutomationPlatformException extends Exception { +public class AutomationPlatformException extends RuntimeException { private final String errorCode; diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java index 57cecb7..57b042f 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java @@ -22,7 +22,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the execution result * @throws AutomationPlatformException if workflow execution fails */ - AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) throws AutomationPlatformException; + AutomationExecutionResult executeWorkflow(String workflowName, Map parameters); /** * Executes a workflow on the automation platform asynchronously. @@ -50,7 +50,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the workflow job status and result * @throws AutomationPlatformException if status check fails */ - AutomationJobStatus getWorkflowJobStatus(String workflowId) throws AutomationPlatformException; + AutomationJobStatus getWorkflowJobStatus(String workflowId); /** From 154b8ab37b95db6ab4cdfe6213ba348075fb0062 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Thu, 26 Mar 2026 13:39:11 +0100 Subject: [PATCH 06/14] Feature/refactor project creation exceptions and introduce command pattern for project creation --- .../facade/impl/ProjectCreationCommand.java | 31 +++ .../impl/ProjectCreationCommandBuilder.java | 139 +++++++++++++ .../mapper/AutomationParametersMapper.java | 26 +++ .../mapper/ProjectCreationResponseMapper.java | 25 +++ .../ProjectCreationCommandBuilderTest.java | 183 ++++++++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java new file mode 100644 index 0000000..2267c0b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java @@ -0,0 +1,31 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ProjectCreationCommand { + + private String projectKey; + + private String projectName; + + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java new file mode 100644 index 0000000..7b1fbab --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -0,0 +1,139 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ProjectCreationCommandBuilder { + + private final ProjectService projectService; + + private final GenerateProjectKeyService generateProjectKeyService; + + public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp, UUID clientId) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + + String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); + String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); + String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); + String location = firstNonBlank(request.getLocation(), flavor.getLocation()); + String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + + return new ProjectCreationCommand( + projectKey, + request.getProjectName(), + request.getProjectDescription(), + projectFlavor, + configurationItem, + location, + request.getX2OdsAccount(), + owner, + clientId); + } + + private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { + List flavors = clientApp.getProjectFlavors(); + + if (flavors == null || flavors.isEmpty()) { + log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); + throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + } + + if (Strings.isNotEmpty(request.getProjectFlavor())) { + return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); + } + + if (Strings.isNotEmpty(request.getConfigurationItem())) { + return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); + } + + throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + } + + private ClientAppProjectFlavorEntity resolveByFlavorName( + String flavorName, List flavors, String clientId) { + return flavors.stream() + .filter(f -> flavorName.equals(f.getName())) + .findFirst() + .orElseThrow(() -> { + log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); + return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + }); + } + + private ClientAppProjectFlavorEntity resolveByConfigurationItem( + String configurationItem, List flavors, String clientId) { + List matchingFlavors = flavors.stream() + .filter(f -> configurationItem.equals(f.getConfigItem())) + .toList(); + + if (matchingFlavors.size() != 1) { + log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", + configurationItem, clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); + + if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { + log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", + configurationItem, matchedFlavor.getName(), clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + return matchedFlavor; + } + + private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + if (Strings.isNotEmpty(existingProjectKey)) { + validateProjectNotExists(existingProjectKey); + return existingProjectKey; + } + + String pattern = flavor.getProjectKeyPattern(); + + try { + String generatedProjectKey = generateProjectKeyService.generateProjectKey(pattern); + validateProjectNotExists(generatedProjectKey); + return generatedProjectKey; + } catch (org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException e) { + throw new ProjectKeyGenerationException("Failed to generate unique project key", e); + } + } + + private void validateProjectNotExists(String projectKey) { + if (projectService.getProject(projectKey) != null) { + throw new ProjectValidationException(ErrorKey.DUPLICATE_RECORD); + } + } + + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { + String[] allowedConfigItems = flavor.getAllowedConfigItems(); + + if (allowedConfigItems == null || allowedConfigItems.length == 0) { + return true; + } + + return Arrays.asList(allowedConfigItems).contains(configurationItem); + } + + private String firstNonBlank(String preferred, String fallback) { + return Strings.isNotEmpty(preferred) ? preferred : fallback; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java new file mode 100644 index 0000000..fa1ea7e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; + +import java.util.HashMap; +import java.util.Map; + +@Mapper(componentModel = "spring") +public interface AutomationParametersMapper { + + default Map toWorkflowParameters(ProjectCreationCommand command, String projectId) { + Map parameters = new HashMap<>(); + parameters.put("geographic_region", command.getLocation()); + parameters.put("project_flavor", command.getProjectFlavor()); + parameters.put("project_owner", command.getOwner()); + parameters.put("project_id", projectId); + parameters.put("configuration_item", command.getConfigurationItem()); + parameters.put("project_key", command.getProjectKey()); + parameters.put("special_account", command.getX2OdsAccount()); + parameters.put("description", command.getProjectDescription()); + parameters.put("project_name", command.getProjectName()); + parameters.put("client_id", command.getClientId().toString()); + return parameters; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java new file mode 100644 index 0000000..4ffb890 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectCreationResponseMapper { + + @Mapping(target = "message", constant = "The project creation process has been successfully initiated.") + @Mapping(target = "status", ignore = true) + @Mapping(target = "httStatus", constant = "OK") + @Mapping(target = "errorKey", constant = "000") + @Mapping(target = "projectKey", ignore = true) + @Mapping(target = "projectFlavor", ignore = true) + @Mapping(target = "location", ignore = true) + @Mapping(target = "error", ignore = true) + @Mapping(target = "errorDescription", ignore = true) + CreateProjectResponse toSuccessResponse(ProjectCreationCommand command, ProjectResponse project); +} + + + diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java new file mode 100644 index 0000000..8a1b842 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -0,0 +1,183 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +class ProjectCreationCommandBuilderTest { + + private static final UUID CLIENT_ID = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + + @Mock + private ProjectService projectService; + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + private ProjectCreationCommandBuilder sut; + + private AutoCloseable mocks; + + @BeforeEach + void set_up() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectCreationCommandBuilder(projectService, generateProjectKeyService); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); + } + + @Test + void build_resolves_defaults_from_flavor_when_request_fields_are_missing() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectService.getProject("KEY01")).thenReturn(null); + + ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + when(projectService.getProject("KEY01")).thenReturn(null); + + ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + } + + @Test + void build_generates_project_key_when_request_project_key_is_null() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); + when(projectService.getProject("DLSS000001")).thenReturn(null); + + ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + + assertEquals("DLSS000001", result.getProjectKey()); + verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); + } + + @Test + void build_throws_validation_exception_when_project_key_already_exists() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectService.getProject("KEY01")).thenReturn(ProjectResponse.builder() + .projectKey("KEY01") + .status(Status.RUNNING) + .build()); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp, CLIENT_ID)); + assertEquals(ErrorKey.DUPLICATE_RECORD, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_flavor_and_config_item_are_missing() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, null, "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp, CLIENT_ID)); + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { + ClientAppEntity clientApp = build_client_app(List.of( + build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"), + build_flavor("AMP", "CI-001", new String[] {}, "eu", "owner2"))); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp, CLIENT_ID)); + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_project_key_generation_exception_when_generation_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")) + .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); + + assertThrows(ProjectKeyGenerationException.class, () -> sut.build(request, clientApp, CLIENT_ID)); + } + + private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey(projectKey); + request.setProjectFlavor(flavor); + request.setConfigurationItem(configItem); + request.setProjectName("Test Project"); + request.setProjectDescription("A test project"); + request.setOwner("testowner"); + request.setLocation("eu"); + request.setX2OdsAccount("x2test"); + return request; + } + + private ClientAppEntity build_client_app(List flavors) { + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(CLIENT_ID.toString()) + .clientName("Test App") + .build(); + entity.setProjectFlavors(flavors); + return entity; + } + + private ClientAppProjectFlavorEntity build_flavor( + String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { + return ClientAppProjectFlavorEntity.builder() + .name(name) + .configItem(configItem) + .allowedConfigItems(allowedConfigItems) + .projectKeyPattern(name + "%06d") + .location(location) + .projectOwner(projectOwner) + .build(); + } +} From 820057e0d1946cfd2f331671c356900e6c9a1ee9 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Thu, 26 Mar 2026 17:57:34 +0100 Subject: [PATCH 07/14] Refactor client app clientId to UUID type and update persistence layer - Change ClientAppEntity.clientId from String to UUID - Update repository methods and service logic to use UUID for clientId - Adjust tests and builders to use UUID clientId - Add migration script to convert client_apps.client_id column to UUID in database - Update related code to ensure type consistency and improve data integrity --- .../impl/ProjectCreationCommandBuilder.java | 6 ++-- .../mapper/ProjectCreationResponseMapper.java | 2 +- .../project/service/ClientAppService.java | 9 +++--- .../ProjectCreationCommandBuilderTest.java | 2 +- .../facade/impl/ProjectsFacadeImplTest.java | 4 +-- .../project/service/ClientAppServiceTest.java | 13 +++++---- .../persistence/dao/ClientDaoImpl.java | 16 +++++++++-- .../persistence/entity/ClientAppEntity.java | 14 ++++++++-- .../repository/ClientAppRepository.java | 6 ++-- ...04_alter_client_apps_client_id_to_uuid.sql | 12 ++++++++ .../repository/ClientAppRepositoryTest.java | 28 +++++++++---------- 11 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index 7b1fbab..05c79bf 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -43,7 +43,7 @@ public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntit location, request.getX2OdsAccount(), owner, - clientId); + clientApp.getId()); } private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { @@ -66,7 +66,7 @@ private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, } private ClientAppProjectFlavorEntity resolveByFlavorName( - String flavorName, List flavors, String clientId) { + String flavorName, List flavors, UUID clientId) { return flavors.stream() .filter(f -> flavorName.equals(f.getName())) .findFirst() @@ -77,7 +77,7 @@ private ClientAppProjectFlavorEntity resolveByFlavorName( } private ClientAppProjectFlavorEntity resolveByConfigurationItem( - String configurationItem, List flavors, String clientId) { + String configurationItem, List flavors, UUID clientId) { List matchingFlavors = flavors.stream() .filter(f -> configurationItem.equals(f.getConfigItem())) .toList(); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java index 4ffb890..0eb9a53 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -13,7 +13,7 @@ public interface ProjectCreationResponseMapper { @Mapping(target = "status", ignore = true) @Mapping(target = "httStatus", constant = "OK") @Mapping(target = "errorKey", constant = "000") - @Mapping(target = "projectKey", ignore = true) + @Mapping(target = "projectKey", source = "project.projectKey") @Mapping(target = "projectFlavor", ignore = true) @Mapping(target = "location", ignore = true) @Mapping(target = "error", ignore = true) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java index dffa620..8c15318 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java @@ -18,12 +18,11 @@ public class ClientAppService { @Transactional(readOnly = true) public ClientAppEntity findByClientId(UUID clientId) { - String clientIdStr = clientId.toString(); - log.debug("Looking up ClientApp for clientId={}", clientIdStr); - return clientAppRepository.findByClientId(clientIdStr) + log.debug("Looking up ClientApp for clientId={}", clientId); + return clientAppRepository.findDetailedByClientId(clientId) .orElseThrow(() -> { - log.warn("ClientApp not found for clientId={}", clientIdStr); - return new ClientAppNotRegisteredException(clientIdStr); + log.warn("ClientApp not found for clientId={}", clientId); + return new ClientAppNotRegisteredException(clientId.toString()); }); } } \ No newline at end of file diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index 8a1b842..9d18dcc 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -162,7 +162,7 @@ private CreateProjectRequest build_request(String flavor, String configItem, Str private ClientAppEntity build_client_app(List flavors) { ClientAppEntity entity = ClientAppEntity.builder() - .clientId(CLIENT_ID.toString()) + .clientId(CLIENT_ID) .clientName("Test App") .build(); entity.setProjectFlavors(flavors); 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 afb35e2..854125c 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 @@ -85,7 +85,7 @@ void tear_down() throws Exception { @Test void create_project_returns_success_response_when_automation_is_successful() { CreateProjectRequest request = new CreateProjectRequest(); - ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID.toString()).build(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); ProjectCreationCommand command = new ProjectCreationCommand( "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); ProjectRequest serviceRequest = new ProjectRequest(); @@ -122,7 +122,7 @@ void create_project_returns_success_response_when_automation_is_successful() { @Test void create_project_throws_project_creation_exception_when_automation_is_not_successful() { CreateProjectRequest request = new CreateProjectRequest(); - ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID.toString()).build(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); ProjectCreationCommand command = new ProjectCreationCommand( "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java index ac87319..a3fd23f 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -1,4 +1,5 @@ package org.opendevstack.apiservice.project.service; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.verify; @@ -30,28 +31,28 @@ void findByClientId_returns_entity_when_client_exists() { UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); ClientAppEntity entity = ClientAppEntity.builder() - .clientId(clientId.toString()) + .clientId(clientId) .clientName("Test App") .build(); - when(clientAppRepository.findByClientId(clientId.toString())).thenReturn(Optional.of(entity)); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.of(entity)); ClientAppEntity result = sut.findByClientId(clientId); assertThat(result).isNotNull(); - assertThat(result.getClientId()).isEqualTo(clientId.toString()); + assertThat(result.getClientId()).isEqualTo(clientId); assertThat(result.getClientName()).isEqualTo("Test App"); - verify(clientAppRepository).findByClientId(clientId.toString()); + verify(clientAppRepository).findDetailedByClientId(clientId); } @Test void findByClientId_throws_exception_when_client_not_found() { UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); - when(clientAppRepository.findByClientId(clientId.toString())).thenReturn(Optional.empty()); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.empty()); assertThrows( ClientAppNotRegisteredException.class, () -> sut.findByClientId(clientId)); - verify(clientAppRepository).findByClientId(clientId.toString()); + verify(clientAppRepository).findDetailedByClientId(clientId); } } \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java index 31db228..d539c21 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.UUID; @Service public class ClientDaoImpl implements ClientDao { @@ -18,10 +19,21 @@ public ClientDaoImpl(ClientAppRepository repository) { @Override public Optional findByClientId(String clientId) { - return repository.findByClientId(clientId) + if (clientId == null || clientId.isBlank()) { + return Optional.empty(); + } + + final UUID clientUuid; + try { + clientUuid = UUID.fromString(clientId); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + + return repository.findByClientId(clientUuid) .map(entity -> new ClientInfo( entity.getId(), - entity.getClientId(), + entity.getClientId().toString(), entity.getClientName(), entity.isEnabled() )); diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java index 74f0875..98a1421 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -1,11 +1,16 @@ package org.opendevstack.apiservice.persistence.entity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,8 +36,8 @@ public class ClientAppEntity extends AuditableEntity { private UUID id; /** Azure AD application/client identifier. */ - @Column(name = "client_id", nullable = false, unique = true, length = 36) - private String clientId; + @Column(name = "client_id", nullable = false, unique = true) + private UUID clientId; /** Optional display name for the client application. */ @Column(name = "client_name", length = 255) @@ -43,4 +48,9 @@ public class ClientAppEntity extends AuditableEntity { @Builder.Default private boolean enabled = true; + /** Project flavor configurations associated with this client. */ + @OneToMany(mappedBy = "clientApp", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List projectFlavors = new ArrayList<>(); + } \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java index 1af9a8d..b088a85 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -19,7 +19,7 @@ public interface ClientAppRepository extends JpaRepository findByClientId(String clientId); + Optional findByClientId(UUID clientId); /** * Returns all enabled client applications. @@ -32,7 +32,7 @@ public interface ClientAppRepository extends JpaRepository findDetailedByClientId(String clientId); + Optional findDetailedByClientId(UUID clientId); } \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql new file mode 100644 index 0000000..852026f --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +--changeset ods:004-alter-client-apps-client-id-to-uuid +-- Description: Converts client_apps.client_id from VARCHAR(36) to UUID to align DB and Java types. +ALTER TABLE client_apps + ALTER COLUMN client_id TYPE UUID + USING client_id::uuid; + +--rollback ALTER TABLE client_apzzps +--rollback ALTER COLUMN client_id TYPE VARCHAR(36) +--rollback USING client_id::text; + diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java index bf31346..4d433e7 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java @@ -1,10 +1,5 @@ package org.opendevstack.apiservice.persistence.repository; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -14,13 +9,18 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + /** * Integration tests for {@link ClientAppRepository}. */ @@ -55,11 +55,11 @@ static void postgresProperties(DynamicPropertyRegistry registry) { void setUp() { repository.deleteAll(); - enabledClientApp = createClientApp("11111111-1111-1111-1111-111111111111", true); - createClientApp("22222222-2222-2222-2222-222222222222", false); + enabledClientApp = createClientApp(UUID.fromString("11111111-1111-1111-1111-111111111111"), true); + createClientApp(UUID.fromString("22222222-2222-2222-2222-222222222222"), false); } - private ClientAppEntity createClientApp(String clientId, boolean enabled) { + private ClientAppEntity createClientApp(UUID clientId, boolean enabled) { ClientAppEntity clientApp = ClientAppEntity.builder() .clientId(clientId) .clientName(enabled ? "Enabled app" : "Disabled app") @@ -76,7 +76,7 @@ class FindByClientId { @Test @DisplayName("returns the matching client app") void returnsMatchingClientApp() { - Optional found = repository.findByClientId("11111111-1111-1111-1111-111111111111"); + Optional found = repository.findByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111")); assertThat(found).isPresent(); assertThat(found.get().getClientName()).isEqualTo("Enabled app"); @@ -86,7 +86,7 @@ void returnsMatchingClientApp() { @Test @DisplayName("returns empty for unknown client id") void returnsEmptyForUnknownClientId() { - assertThat(repository.findByClientId("99999999-9999-9999-9999-999999999999")).isEmpty(); + assertThat(repository.findByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isEmpty(); } } @@ -101,7 +101,7 @@ void returnsOnlyEnabledClientApps() { List enabledApps = repository.findByEnabledTrue(); assertThat(enabledApps).hasSize(1); - assertThat(enabledApps.get(0).getClientId()).isEqualTo("11111111-1111-1111-1111-111111111111"); + assertThat(enabledApps.get(0).getClientId().toString()).isEqualTo("11111111-1111-1111-1111-111111111111"); } } @@ -112,13 +112,13 @@ class ExistsByClientId { @Test @DisplayName("returns true for existing client id") void returnsTrueForExistingClientId() { - assertThat(repository.existsByClientId("11111111-1111-1111-1111-111111111111")).isTrue(); + assertThat(repository.existsByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111"))).isTrue(); } @Test @DisplayName("returns false for unknown client id") void returnsFalseForUnknownClientId() { - assertThat(repository.existsByClientId("99999999-9999-9999-9999-999999999999")).isFalse(); + assertThat(repository.existsByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isFalse(); } } From 8e420f11f8f35b7b6be1e214ac6766905e0f6b35 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Fri, 27 Mar 2026 13:05:04 +0100 Subject: [PATCH 08/14] Refactor ProjectCreationCommandBuilder: remove unused clientId parameter from build method and update usages --- .../project/facade/impl/ProjectCreationCommandBuilder.java | 2 +- .../apiservice/project/facade/impl/ProjectsFacadeImpl.java | 2 +- .../project/facade/impl/ProjectsFacadeImplTest.java | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index 05c79bf..2ec0d20 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -25,7 +25,7 @@ public class ProjectCreationCommandBuilder { private final GenerateProjectKeyService generateProjectKeyService; - public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp, UUID clientId) { + public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp) { ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index b2047ff..cfbfbdc 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -62,7 +62,7 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl ClientAppEntity clientApp = clientAppService.findByClientId(clientId); - ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp, clientId); + ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); ProjectResponse project = projectService.createProject(projectMapper.toServiceRequest(command)); 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 311deee..c61cdaf 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 @@ -100,7 +100,7 @@ void create_project_returns_success_response_when_automation_is_successful() { apiResponse.setProjectFlavor("DLSS"); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectCreationCommandBuilder.build(request, clientApp, CLIENT_ID)).thenReturn(command); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); when(projectService.createProject(serviceRequest)).thenReturn(projectResponse); when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) @@ -113,7 +113,7 @@ void create_project_returns_success_response_when_automation_is_successful() { assertEquals("Pending", result.getStatus()); assertEquals("DLSS", result.getProjectFlavor()); - verify(projectCreationCommandBuilder).build(request, clientApp, CLIENT_ID); + verify(projectCreationCommandBuilder).build(request, clientApp); verify(projectMapper).toServiceRequest(command); verify(automationParametersMapper) .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); @@ -128,7 +128,7 @@ void create_project_throws_project_creation_exception_when_automation_is_not_suc "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectCreationCommandBuilder.build(request, clientApp, CLIENT_ID)).thenReturn(command); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); when(projectService.createProject(any(ProjectRequest.class))) .thenReturn(ProjectResponse.builder() From acafb6203ba7dc4e83e8c10fea4620f9b1bb6ad2 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Fri, 27 Mar 2026 13:15:11 +0100 Subject: [PATCH 09/14] Update ProjectCreationCommandBuilderTest: remove clientId parameter from build method calls --- .../impl/ProjectCreationCommandBuilderTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index 9d18dcc..3a9fffe 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -58,7 +58,7 @@ void build_resolves_defaults_from_flavor_when_request_fields_are_missing() { when(projectService.getProject("KEY01")).thenReturn(null); - ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + ProjectCreationCommand result = sut.build(request, clientApp); assertEquals("DLSS", result.getProjectFlavor()); assertEquals("CI-001", result.getConfigurationItem()); @@ -75,7 +75,7 @@ void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() when(projectService.getProject("KEY01")).thenReturn(null); - ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + ProjectCreationCommand result = sut.build(request, clientApp); assertEquals("DLSS", result.getProjectFlavor()); assertEquals("CI-001", result.getConfigurationItem()); @@ -90,7 +90,7 @@ void build_generates_project_key_when_request_project_key_is_null() throws Excep when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); when(projectService.getProject("DLSS000001")).thenReturn(null); - ProjectCreationCommand result = sut.build(request, clientApp, CLIENT_ID); + ProjectCreationCommand result = sut.build(request, clientApp); assertEquals("DLSS000001", result.getProjectKey()); verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); @@ -108,7 +108,7 @@ void build_throws_validation_exception_when_project_key_already_exists() { .build()); ProjectValidationException ex = assertThrows(ProjectValidationException.class, - () -> sut.build(request, clientApp, CLIENT_ID)); + () -> sut.build(request, clientApp)); assertEquals(ErrorKey.DUPLICATE_RECORD, ex.getErrorKey()); } @@ -119,7 +119,7 @@ void build_throws_validation_exception_when_flavor_and_config_item_are_missing() CreateProjectRequest request = build_request(null, null, "KEY01"); ProjectValidationException ex = assertThrows(ProjectValidationException.class, - () -> sut.build(request, clientApp, CLIENT_ID)); + () -> sut.build(request, clientApp)); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); } @@ -131,7 +131,7 @@ void build_throws_validation_exception_when_configuration_item_matches_multiple_ CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); ProjectValidationException ex = assertThrows(ProjectValidationException.class, - () -> sut.build(request, clientApp, CLIENT_ID)); + () -> sut.build(request, clientApp)); assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); } @@ -144,7 +144,7 @@ void build_throws_project_key_generation_exception_when_generation_fails() throw when(generateProjectKeyService.generateProjectKey("DLSS%06d")) .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); - assertThrows(ProjectKeyGenerationException.class, () -> sut.build(request, clientApp, CLIENT_ID)); + assertThrows(ProjectKeyGenerationException.class, () -> sut.build(request, clientApp)); } private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { From 46ccaa337367c0b43ffe0b97f771b3ae35a246de Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Mon, 30 Mar 2026 16:16:12 +0200 Subject: [PATCH 10/14] Refactor project key test UUIDs and externalize LDAP group pattern in project service - Update hardcoded test UUIDs to a consistent value across tests and controller - Externalize LDAP group DN pattern in ProjectServiceImpl using @Value and configurable property - Refactor LDAP group generation to use injected pattern and role constants - Clarify API schema descriptions and correct field patterns for x2OdsAccount and owner --- api-project/openapi/api-project.yaml | 8 ++++---- .../project/controller/ProjectController.java | 2 +- .../ProjectCreationCommandBuilderTest.java | 2 +- .../facade/impl/ProjectsFacadeImplTest.java | 2 +- .../project/service/ClientAppServiceTest.java | 2 +- .../service/impl/ProjectServiceImpl.java | 18 ++++++++++++++---- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 986c5f2..26f00f7 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -138,21 +138,21 @@ components: maxLength: 255 projectFlavor: type: string - description: Flavor of the project. Either projectFlavor or configurationItem must be provided. + description: Project flavor. Must be provided if configurationItem is not present. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. configurationItem: type: string - description: Configuration item for the project. Either projectFlavor or configurationItem must be provided. + description: Configuration item. Must be present if projectFlavor is not provided. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. location: type: string description: Location of the project. Must be one of the allowed locations. If not in the allowed list, error INVALID_LOCATION (011) is returned. x2OdsAccount: type: string description: Technical account of the project. - pattern: "^[a-z]{1,10}$" + pattern: "^x2[a-zA-Z0-9]{0,13}$" owner: type: string description: Owner of the project. If x2OdsAccount is provided but owner is missing, error MANDATORY_OWNER (024) is returned. - pattern: "^x2[a-zA-Z0-9]{0,13}$" + pattern: "^[a-z]{1,10}$" oneOf: - required: [projectFlavor] - required: [configurationItem] 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 47b80d4..b714133 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 @@ -37,7 +37,7 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); return ResponseEntity diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index 3a9fffe..c293bb6 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -25,7 +25,7 @@ class ProjectCreationCommandBuilderTest { - private static final UUID CLIENT_ID = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); @Mock private ProjectService projectService; 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 c61cdaf..df195b0 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 @@ class ProjectsFacadeImplTest { - private static final UUID CLIENT_ID = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); @Mock private ProjectService projectService; diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java index a3fd23f..a0737fa 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -29,7 +29,7 @@ void setUp() { @Test void findByClientId_returns_entity_when_client_exists() { - UUID clientId = UUID.fromString("56a0fc62-bf77-4acb-8cd7-8cc9f5f2198f"); + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); ClientAppEntity entity = ClientAppEntity.builder() .clientId(clientId) .clientName("Test App") diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 98370bc..0086c1c 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -8,6 +8,7 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.Optional; @@ -15,7 +16,14 @@ @Service @Slf4j public class ProjectServiceImpl implements ProjectService { + + private final String MANAGER_ROLE = "MANAGER"; + private final String TEAM_ROLE = "TEAM"; + private final String STAKEHOLDER_ROLE = "STAKEHOLDER"; + @Value("${ldap.group.pattern}") + private String ldapGroupPattern; + private final ProjectRepository projectRepository; private final ProjectResponseMapper projectResponseMapper; @@ -34,9 +42,9 @@ public ProjectResponse createProject(ProjectRequest request) { entity.setProjectFlavor(request.getProjectFlavor()); entity.setConfigurationItem(request.getConfigurationItem()); entity.setDescription(request.getProjectFlavor() + " project"); - entity.setLdapGroupManager(getLdapGroup("MANAGER", request.getProjectKey())); - entity.setLdapGroupTeam(getLdapGroup("TEAM", request.getProjectKey())); - entity.setLdapGroupStakeholder(getLdapGroup("STAKEHOLDER", request.getProjectKey())); + entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); + entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); + entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); entity.setStatus(Status.PENDING.getDbValue()); entity.setLocation(request.getLocation()); ProjectEntity save = projectRepository.save(entity); @@ -55,7 +63,9 @@ public ProjectResponse getProject(String projectKey) { } private String getLdapGroup(String role, String projectKey) { - return "CN=BI-AS-ATLASSIAN-P-" + projectKey + "-" + role + ",OU=BIDS-managed,DC=eu,DC=boehringer,DC=com"; + return ldapGroupPattern + .replace("{{projectKey}}", projectKey) + .replace("{{role}}", role); } } From e4641bde0e0fa27a295c2c09149398d22d17c23c Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 10:22:12 +0200 Subject: [PATCH 11/14] Refactor project creation flow: rename createProject to saveProject, add status handling, and update exception response --- .../controller/advice/ProjectExceptionHandler.java | 6 +++--- .../project/facade/impl/ProjectsFacadeImpl.java | 10 +++++++++- .../advice/ProjectExceptionHandlerTest.java | 10 +++++----- .../project/facade/impl/ProjectsFacadeImplTest.java | 4 ++-- .../serviceproject/model/ProjectRequest.java | 6 ++++-- .../serviceproject/service/ProjectService.java | 2 +- .../service/impl/ProjectServiceImpl.java | 11 +++++------ 7 files changed, 29 insertions(+), 20 deletions(-) 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 index 901715b..f7ab1bf 100644 --- 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 @@ -91,10 +91,10 @@ public ResponseEntity handleProjectCreationException( log.error("Project creation error: {}", ex.getMessage(), ex); CreateProjectResponse response = new CreateProjectResponse(); response.setLocation(ProjectController.API_BASE_PATH); - response.setError(ErrorKey.PROJECT_ALREADY_EXISTS.getMessage()); - response.setErrorKey(ErrorKey.PROJECT_ALREADY_EXISTS.getKey()); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); response.setMessage(ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } @ExceptionHandler(ProjectKeyGenerationException.class) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index cfbfbdc..20e8567 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -12,7 +12,9 @@ import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -64,7 +66,10 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); - ProjectResponse project = projectService.createProject(projectMapper.toServiceRequest(command)); + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.PENDING); + + ProjectResponse project = projectService.saveProject(projectRequest); AutomationExecutionResult automationExecutionResult = automationPlatformService .executeWorkflow( @@ -76,6 +81,9 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl if (automationExecutionResult.isSuccessful()) { return projectCreationResponseMapper.toSuccessResponse(command, project); } else { + projectRequest.setProjectId(project.getProjectId()); + projectRequest.setStatus(Status.FAILED); + projectService.saveProject(projectRequest); throw new ProjectCreationException("Failed to create project: " + automationExecutionResult.getErrorDetails()); } 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 af41656..e5bb637 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 @@ -205,16 +205,16 @@ private static Stream provideValidationCases() { @Test void handle_project_creation_exception_returns_conflict() { - ProjectCreationException exception = new ProjectCreationException("Project with key 'EXISTING' already exists"); + ProjectCreationException exception = new ProjectCreationException("error message"); ResponseEntity result = sut.handleProjectCreationException(exception); - assertEquals(HttpStatus.CONFLICT, result.getStatusCode()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); assertNotNull(result.getBody()); assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); - assertEquals("Project already exists", result.getBody().getError()); - assertEquals("025", result.getBody().getErrorKey()); - assertEquals("Project with key 'EXISTING' already exists", result.getBody().getMessage()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("error message", 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 df195b0..eb15edd 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 @@ -102,7 +102,7 @@ void create_project_returns_success_response_when_automation_is_successful() { when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); - when(projectService.createProject(serviceRequest)).thenReturn(projectResponse); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) .thenReturn(Map.of("project_key", "DLSS01")); when(automationPlatformService.executeWorkflow(anyString(), anyMap())) @@ -130,7 +130,7 @@ void create_project_throws_project_creation_exception_when_automation_is_not_suc when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); - when(projectService.createProject(any(ProjectRequest.class))) + when(projectService.saveProject(any(ProjectRequest.class))) .thenReturn(ProjectResponse.builder() .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) .projectKey("DLSS01") diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index a20476c..9bc0f91 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -10,14 +10,14 @@ @NoArgsConstructor @AllArgsConstructor public class ProjectRequest { + + private UUID projectId; private String projectKey; private String projectName; private String projectDescription; - - private UUID projectId; private String projectFlavor; @@ -30,4 +30,6 @@ public class ProjectRequest { private String owner; private UUID clientId; + + private Status status; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index c2abcc6..f2ba3aa 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -5,7 +5,7 @@ public interface ProjectService { - ProjectResponse createProject(ProjectRequest request); + ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 0086c1c..ccd3be4 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -6,7 +6,6 @@ import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -17,9 +16,9 @@ @Slf4j public class ProjectServiceImpl implements ProjectService { - private final String MANAGER_ROLE = "MANAGER"; - private final String TEAM_ROLE = "TEAM"; - private final String STAKEHOLDER_ROLE = "STAKEHOLDER"; + private static final String MANAGER_ROLE = "MANAGER"; + private static final String TEAM_ROLE = "TEAM"; + private static final String STAKEHOLDER_ROLE = "STAKEHOLDER"; @Value("${ldap.group.pattern}") private String ldapGroupPattern; @@ -35,7 +34,7 @@ public ProjectServiceImpl(ProjectRepository projectRepository, } @Override - public ProjectResponse createProject(ProjectRequest request) { + public ProjectResponse saveProject(ProjectRequest request) { ProjectEntity entity = new ProjectEntity(); entity.setProjectKey(request.getProjectKey()); entity.setProjectName(request.getProjectName()); @@ -45,7 +44,7 @@ public ProjectResponse createProject(ProjectRequest request) { entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); - entity.setStatus(Status.PENDING.getDbValue()); + entity.setStatus(request.getStatus().getDbValue()); entity.setLocation(request.getLocation()); ProjectEntity save = projectRepository.save(entity); return projectResponseMapper.toCreateProjectResponse(save); From 50a916a0354dbd5016382292b0cadda3b24a553d Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 12:24:54 +0200 Subject: [PATCH 12/14] Refactor project key existence checks: introduce ProjectExistenceService, update key generation and validation logic, and remove redundant exception handler --- .../advice/ProjectExceptionHandler.java | 13 -- .../ProjectKeyGenerationException.java | 12 -- .../impl/ProjectCreationCommandBuilder.java | 50 +++---- .../advice/ProjectExceptionHandlerTest.java | 21 +-- .../ProjectCreationCommandBuilderTest.java | 53 ++++--- .../ProjectExistenceServiceException.java | 12 ++ .../service/GenerateProjectKeyService.java | 3 +- .../service/ProjectExistenceService.java | 8 + .../impl/GenerateProjectKeyServiceImpl.java | 99 ++----------- .../impl/ProjectExistenceServiceImpl.java | 84 +++++++++++ .../GenerateProjectKeyServiceImplTest.java | 45 ++---- .../impl/ProjectExistenceServiceImplTest.java | 137 ++++++++++++++++++ 12 files changed, 315 insertions(+), 222 deletions(-) delete mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java 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 index f7ab1bf..56308e1 100644 --- 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 @@ -6,7 +6,6 @@ import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.springframework.http.HttpStatus; @@ -97,18 +96,6 @@ public ResponseEntity handleProjectCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } - @ExceptionHandler(ProjectKeyGenerationException.class) - public ResponseEntity handleProjectKeyGenerationException( - ProjectKeyGenerationException ex) { - log.error("Failed to generate project key: {}", ex.getMessage(), ex); - CreateProjectResponse response = new CreateProjectResponse(); - response.setLocation(ProjectController.API_BASE_PATH); - response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); - response.setErrorKey("PROJECT_KEY_GENERATION_FAILED"); - response.setMessage("Failed to generate a unique project key."); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); - } - @ExceptionHandler(AutomationPlatformException.class) public ResponseEntity handleAutomationPlatformException( AutomationPlatformException ex) { diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java deleted file mode 100644 index 954a6bd..0000000 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.opendevstack.apiservice.project.exception; - -public class ProjectKeyGenerationException extends RuntimeException { - - public ProjectKeyGenerationException(String message) { - super(message); - } - - public ProjectKeyGenerationException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index 2ec0d20..c15065f 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -1,29 +1,32 @@ package org.opendevstack.apiservice.project.facade.impl; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.util.Strings; import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; -import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import org.springframework.stereotype.Component; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + @Component @RequiredArgsConstructor @Slf4j public class ProjectCreationCommandBuilder { - - private final ProjectService projectService; private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectExistenceService projectExistenceService; public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp) { ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); @@ -100,25 +103,22 @@ private ClientAppProjectFlavorEntity resolveByConfigurationItem( } private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { - if (Strings.isNotEmpty(existingProjectKey)) { - validateProjectNotExists(existingProjectKey); - return existingProjectKey; - } - - String pattern = flavor.getProjectKeyPattern(); - try { - String generatedProjectKey = generateProjectKeyService.generateProjectKey(pattern); - validateProjectNotExists(generatedProjectKey); - return generatedProjectKey; - } catch (org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException e) { - throw new ProjectKeyGenerationException("Failed to generate unique project key", e); - } - } - - private void validateProjectNotExists(String projectKey) { - if (projectService.getProject(projectKey) != null) { - throw new ProjectValidationException(ErrorKey.DUPLICATE_RECORD); + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFound(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectValidationException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); } } 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 e5bb637..45435a0 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 @@ -12,7 +12,6 @@ import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -26,9 +25,9 @@ import java.util.stream.Stream; 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; +import static org.junit.jupiter.api.Assertions.fail; class ProjectExceptionHandlerTest { @@ -220,24 +219,6 @@ void handle_project_creation_exception_returns_conflict() { assertNull(result.getBody().getErrorDescription()); } - @Test - void handle_project_key_generation_exception_returns_internal_server_error() { - ProjectKeyGenerationException exception = new ProjectKeyGenerationException( - "Failed to generate unique project key after 10 retries"); - - ResponseEntity result = sut.handleProjectKeyGenerationException(exception); - - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); - assertNotNull(result.getBody()); - assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); - assertEquals("Internal error", result.getBody().getError()); - assertEquals("PROJECT_KEY_GENERATION_FAILED", result.getBody().getErrorKey()); - assertEquals("Failed to generate a unique project key.", result.getBody().getMessage()); - assertNull(result.getBody().getProjectKey()); - assertNull(result.getBody().getStatus()); - assertNull(result.getBody().getErrorDescription()); - } - @Test void handle_client_app_not_registered_exception_returns_forbidden() { ClientAppNotRegisteredException exception = new ClientAppNotRegisteredException("client-123"); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index c293bb6..c537efd 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -1,12 +1,5 @@ package org.opendevstack.apiservice.project.facade.impl; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,23 +8,30 @@ import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; -import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; -import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ProjectCreationCommandBuilderTest { private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); - @Mock - private ProjectService projectService; - @Mock private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectExistenceService projectExistenceService; private ProjectCreationCommandBuilder sut; @@ -40,7 +40,7 @@ class ProjectCreationCommandBuilderTest { @BeforeEach void set_up() { mocks = MockitoAnnotations.openMocks(this); - sut = new ProjectCreationCommandBuilder(projectService, generateProjectKeyService); + sut = new ProjectCreationCommandBuilder(generateProjectKeyService, projectExistenceService); } @AfterEach @@ -49,14 +49,14 @@ void tear_down() throws Exception { } @Test - void build_resolves_defaults_from_flavor_when_request_fields_are_missing() { + void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); request.setOwner(null); request.setLocation(null); - when(projectService.getProject("KEY01")).thenReturn(null); + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); ProjectCreationCommand result = sut.build(request, clientApp); @@ -68,12 +68,12 @@ void build_resolves_defaults_from_flavor_when_request_fields_are_missing() { } @Test - void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() { + void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); - when(projectService.getProject("KEY01")).thenReturn(null); + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); ProjectCreationCommand result = sut.build(request, clientApp); @@ -82,13 +82,13 @@ void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() } @Test - void build_generates_project_key_when_request_project_key_is_null() throws Exception { + void build_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, null); when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); - when(projectService.getProject("DLSS000001")).thenReturn(null); + when(projectExistenceService.isProjectFound("DLSS000001")).thenReturn(false); ProjectCreationCommand result = sut.build(request, clientApp); @@ -97,19 +97,16 @@ void build_generates_project_key_when_request_project_key_is_null() throws Excep } @Test - void build_throws_validation_exception_when_project_key_already_exists() { + void build_throws_validation_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); - when(projectService.getProject("KEY01")).thenReturn(ProjectResponse.builder() - .projectKey("KEY01") - .status(Status.RUNNING) - .build()); + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); ProjectValidationException ex = assertThrows(ProjectValidationException.class, () -> sut.build(request, clientApp)); - assertEquals(ErrorKey.DUPLICATE_RECORD, ex.getErrorKey()); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); } @Test @@ -144,7 +141,7 @@ void build_throws_project_key_generation_exception_when_generation_fails() throw when(generateProjectKeyService.generateProjectKey("DLSS%06d")) .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); - assertThrows(ProjectKeyGenerationException.class, () -> sut.build(request, clientApp)); + assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); } private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java new file mode 100644 index 0000000..fa3a5d0 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectExistenceServiceException extends Exception { + + public ProjectExistenceServiceException(String message) { + super(message); + } + + public ProjectExistenceServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java index 289b977..b00d7bf 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -1,11 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; public interface GenerateProjectKeyService { String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; - String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException; + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java new file mode 100644 index 0000000..ed13614 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; + +public interface ProjectExistenceService { + + boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java index 2e53781..7b80fd3 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -1,21 +1,15 @@ package org.opendevstack.apiservice.serviceproject.service.impl; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.security.SecureRandom; -import java.util.Comparator; import java.util.Random; -import java.util.Set; @Service @Slf4j @@ -23,37 +17,29 @@ public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService private static final int MAX_RETRIES = 10; - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; + private final ProjectExistenceService projectExistenceService; private final Random random; @Autowired - public GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService) { - this(bitbucketService, jiraService, openshiftService, new SecureRandom()); + public GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService) { + this(projectExistenceService, new SecureRandom()); } - GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService, Random random) { - this.bitbucketService = bitbucketService; - this.jiraService = jiraService; - this.openshiftService = openshiftService; + GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService, Random random) { + this.projectExistenceService = projectExistenceService; this.random = random; } @Override - public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException { + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException { String pattern = resolveProjectKeyPattern(projectKeyPattern); for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { int randomNumber = random.nextInt(1_000_000); String projectKey = String.format(pattern, randomNumber); - if (!isProjectFound(projectKey)) { + if (!projectExistenceService.isProjectFound(projectKey)) { log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); return projectKey; } @@ -71,71 +57,4 @@ private String resolveProjectKeyPattern(String projectKeyPattern) { } return projectKeyPattern; } - - private boolean isProjectFound(String projectKey) throws ProjectKeyGenerationException { - try { - if (existsInAnyBitbucketInstance(projectKey)) { - return true; - } - - if (existsInAnyJiraInstance(projectKey)) { - return true; - } - - return existsInAnyOpenshift(projectKey); - } catch (BitbucketException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Bitbucket", projectKey), e); - } catch (JiraException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Jira", projectKey), e); - } catch (OpenshiftException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Openshift", projectKey), e); - } - } - - private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { - Set instances = bitbucketService.getAvailableInstances(); - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (bitbucketService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { - Set instances = jiraService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return jiraService.projectExists(projectKey); - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (jiraService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { - Set instances = openshiftService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return false; - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (openshiftService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java new file mode 100644 index 0000000..a448376 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -0,0 +1,84 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.Set; + +@Service +public class ProjectExistenceServiceImpl implements ProjectExistenceService { + + private final BitbucketService bitbucketService; + private final JiraService jiraService; + private final OpenshiftService openshiftService; + private final ProjectService projectService; + + @Autowired + public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, ProjectService projectService) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.projectService = projectService; + } + + @Override + public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { + try { + if (existsInCollection(projectKey)) return true; + if (existsInAnyBitbucketInstance(projectKey)) return true; + if (existsInAnyJiraInstance(projectKey)) return true; + return existsInAnyOpenshift(projectKey); + } catch (BitbucketException e) { + throw new ProjectExistenceServiceException("Failed to check project in Bitbucket", e); + } catch (JiraException e) { + throw new ProjectExistenceServiceException("Failed to check project in Jira", e); + } catch (OpenshiftException e) { + throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); + } catch (RuntimeException e) { + throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); + } + } + + private boolean existsInCollection(String projectKey) { + return projectService.getProject(projectKey) != null; + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) return false; + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instance, projectKey)) return true; + } + return false; + } +} diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java index ebba523..6e5b02f 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -7,13 +7,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import java.util.Random; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,13 +22,7 @@ class GenerateProjectKeyServiceImplTest { @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private OpenshiftService openshiftService; + private ProjectExistenceService projectExistenceService; @Mock private Random random; @@ -40,17 +31,13 @@ class GenerateProjectKeyServiceImplTest { @BeforeEach void setup() { - tested = new GenerateProjectKeyServiceImpl(bitbucketService, jiraService, openshiftService, random); - when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("dev")); - when(jiraService.getAvailableInstances()).thenReturn(Set.of("default")); - when(openshiftService.getAvailableInstances()).thenReturn(Set.of()); + tested = new GenerateProjectKeyServiceImpl(projectExistenceService, random); } @Test - void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exception { + void generate_project_key_returns_key_when_first_candidate_is_free() throws Exception { when(random.nextInt(1_000_000)).thenReturn(7); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000007")).thenReturn(false); String result = tested.generateProjectKey(null); @@ -58,14 +45,10 @@ void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exceptio } @Test - void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws Exception { + void generate_project_key_retries_until_unique_key_found() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2); - - when(bitbucketService.projectExists("dev", "SS000001")).thenReturn(true); - when(jiraService.projectExists("default", "SS000001")).thenReturn(false); - - when(bitbucketService.projectExists("dev", "SS000002")).thenReturn(false); - when(jiraService.projectExists("default", "SS000002")).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000001")).thenReturn(true); + when(projectExistenceService.isProjectFound("SS000002")).thenReturn(false); String result = tested.generateProjectKey("SS%06d"); @@ -73,12 +56,9 @@ void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws E } @Test - void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() throws Exception { + void generate_project_key_throws_exception_when_no_unique_key_after_max_retries() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); - - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - - when(jiraService.projectExists(anyString(), anyString())).thenReturn(true); + when(projectExistenceService.isProjectFound(anyString())).thenReturn(true); assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) .isInstanceOf(ProjectKeyGenerationException.class) @@ -86,10 +66,9 @@ void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() thro } @Test - void generateProjectKey_whenCustomPatternProvided_thenUseIt() throws Exception { + void generate_project_key_uses_custom_pattern_when_provided() throws Exception { when(random.nextInt(1_000_000)).thenReturn(42); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("AB0042")).thenReturn(false); String result = tested.generateProjectKey("AB%04d"); diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java new file mode 100644 index 0000000..329a5b6 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -0,0 +1,137 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProjectExistenceServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + @Mock + private JiraService jiraService; + @Mock + private OpenshiftService openshiftService; + @Mock + private ProjectService projectService; + + private ProjectExistenceServiceImpl sut; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this).close(); + sut = new ProjectExistenceServiceImpl(bitbucketService, jiraService, openshiftService, projectService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.getProject("KEY1")).thenReturn(new ProjectResponse()); + + boolean result = sut.isProjectFound("KEY1"); + assertTrue(result); + verify(projectService).getProject("KEY1"); + verifyNoInteractions(bitbucketService, jiraService, openshiftService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_bitbucket() throws Exception { + when(projectService.getProject("KEY2")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY2")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY2"); + assertTrue(result); + verify(bitbucketService).projectExists("inst1", "KEY2"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_jira() throws Exception { + when(projectService.getProject("KEY3")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY3")).thenReturn(true); + boolean result = sut.isProjectFound("KEY3"); + assertTrue(result); + verify(jiraService).projectExists("jira1", "KEY3"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_openshift() throws Exception { + when(projectService.getProject("KEY4")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + when(openshiftService.projectExists("ocp1", "KEY4")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY4"); + assertTrue(result); + verify(openshiftService).projectExists("ocp1", "KEY4"); + } + + @Test + void is_project_found_returns_false_when_project_not_found_anywhere() throws Exception { + when(projectService.getProject("KEY5")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY5")).thenReturn(false); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY5")).thenReturn(false); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + + when(openshiftService.projectExists("ocp1", "KEY5")).thenReturn(false); + boolean result = sut.isProjectFound("KEY5"); + assertFalse(result); + } + + @Test + void is_project_found_throws_exception_when_bitbucket_fails() { + when(projectService.getProject("KEY6")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + ProjectExistenceServiceException ex = assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY6"); + }); + } + + @Test + void is_project_found_throws_exception_when_jira_fails() { + when(projectService.getProject("KEY7")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + ProjectExistenceServiceException ex = assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY7"); + }); + } + + @Test + void is_project_found_throws_exception_when_openshift_fails() { + when(projectService.getProject("KEY8")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY8"); + }); + } +} \ No newline at end of file From fb8e595ba47d89ae84fb61c85ab2912a494c8453 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 12:49:17 +0200 Subject: [PATCH 13/14] Refactor ProjectCreationCommandBuilder and ProjectsFacadeImpl: improve default value handling and streamline workflow parameter mapping --- .../facade/impl/ProjectCreationCommandBuilder.java | 9 ++++++--- .../project/facade/impl/ProjectsFacadeImpl.java | 10 +++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index c15065f..10b087d 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -34,17 +34,20 @@ public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntit String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); + String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); String location = firstNonBlank(request.getLocation(), flavor.getLocation()); String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + String projectName = firstNonBlank(request.getProjectName(), projectKey); + String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); return new ProjectCreationCommand( projectKey, - request.getProjectName(), - request.getProjectDescription(), + projectName, + projectDescription, projectFlavor, configurationItem, location, - request.getX2OdsAccount(), + x2account, owner, clientApp.getId()); } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 20e8567..87a9046 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Map; import java.util.UUID; @Component("apiProjectFacadeImpl") @@ -71,12 +72,11 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl ProjectResponse project = projectService.saveProject(projectRequest); + String projectId = project.getProjectId().toString(); + Map workflowParameters = automationParametersMapper.toWorkflowParameters(command, projectId); + AutomationExecutionResult automationExecutionResult = automationPlatformService - .executeWorkflow( - createProjectWorkflow, - automationParametersMapper.toWorkflowParameters( - command, - project.getProjectId().toString())); + .executeWorkflow(createProjectWorkflow, workflowParameters); if (automationExecutionResult.isSuccessful()) { return projectCreationResponseMapper.toSuccessResponse(command, project); From 629fd5cf5ad688e7c9821454d0630643e54ca36a Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 13:04:56 +0200 Subject: [PATCH 14/14] Fixed sonarq issues --- .../service/impl/ProjectExistenceServiceImplTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java index 329a5b6..aaabce3 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -103,7 +103,7 @@ void is_project_found_returns_false_when_project_not_found_anywhere() throws Exc void is_project_found_throws_exception_when_bitbucket_fails() { when(projectService.getProject("KEY6")).thenReturn(null); when(bitbucketService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); - ProjectExistenceServiceException ex = assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); assertThrows(ProjectExistenceServiceException.class, () -> { sut.isProjectFound("KEY6"); @@ -116,7 +116,7 @@ void is_project_found_throws_exception_when_jira_fails() { when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); when(jiraService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); - ProjectExistenceServiceException ex = assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); assertThrows(ProjectExistenceServiceException.class, () -> { sut.isProjectFound("KEY7");