diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 8fbd57b..9c83d30 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -142,6 +142,8 @@ components: type: string status: type: string + projectFlavor: + type: string message: type: string error: @@ -150,3 +152,5 @@ components: type: string errorDescription: type: string + location: + type: string diff --git a/api-project/pom.xml b/api-project/pom.xml index 8daceb0..e77eda9 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -74,7 +74,7 @@ org.mapstruct mapstruct - 1.6.3 + ${mapstruct.version} @@ -111,7 +111,7 @@ org.mapstruct mapstruct-processor - 1.6.3 + ${mapstruct.version} 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 a17d338..fe8012d 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 @@ -26,38 +26,51 @@ public class ProjectController implements ProjectsApi { public static final String API_BASE_PATH = "/api/pub/v0/projects"; + private static final String HTTP_HEADER_LOCATION = "Location"; + private final ProjectsFacade projectsFacade; @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { try { - return ResponseEntity.ok(projectsFacade.createProject(createProjectRequest)); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectsFacade.createProject(createProjectRequest)); } catch (ProjectCreationException e) { log.error("Project creation conflict: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT) - .body(ProjectResponseFactory.conflict(e.getMessage())); + .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) - .body(ProjectResponseFactory.projectKeyGenerationFailed()); + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); } } @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) - .body(ProjectResponseFactory.notFound(projectKey)); + .header(HTTP_HEADER_LOCATION, location) + .body(ProjectResponseFactory.notFound(projectKey, location)); } - return ResponseEntity.ok(response); - } catch (ProjectCreationException e) { - log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e); + 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) - .body(ProjectResponseFactory.internalError()); + .header(HTTP_HEADER_LOCATION, location) + .body(ProjectResponseFactory.internalError(location)); } } } 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 6a6faae..b1ed5d2 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 @@ -1,38 +1,50 @@ package org.opendevstack.apiservice.project.controller; +import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.model.CreateProjectResponse; public final class ProjectResponseFactory { - private static final String INTERNAL_ERROR = "INTERNAL_ERROR"; - private ProjectResponseFactory() { } - public static CreateProjectResponse conflict(String message) { - return error("CONFLICT", "PROJECT_ALREADY_EXISTS", message); + public static CreateProjectResponse conflict(String message, String location) { + return error( + ErrorKey.PROJECT_ALREADY_EXISTS.getMessage(), + ErrorKey.PROJECT_ALREADY_EXISTS.getKey(), + message, location); } - public static CreateProjectResponse projectKeyGenerationFailed() { - return error(INTERNAL_ERROR, "PROJECT_KEY_GENERATION_FAILED", - "Failed to generate a unique project key."); + public static CreateProjectResponse projectKeyGenerationFailed(String location) { + return error(ErrorKey.INTERNAL_ERROR.getMessage(), + "PROJECT_KEY_GENERATION_FAILED", + "Failed to generate a unique project key.", + location); } - public static CreateProjectResponse notFound(String projectKey) { - return error("NOT_FOUND", "PROJECT_NOT_FOUND", - String.format("Project with key '%s' not found", projectKey)); + public static CreateProjectResponse notFound(String projectKey, String location) { + return error( + ErrorKey.PROJECT_NOT_FOUND.getMessage(), + ErrorKey.PROJECT_NOT_FOUND.getKey(), + String.format("Project with key '%s' not found", projectKey), + location + ); } - public static CreateProjectResponse internalError() { - return error(INTERNAL_ERROR, INTERNAL_ERROR, - "An error occurred while processing the request."); + public static CreateProjectResponse internalError(String location) { + return error( + ErrorKey.INTERNAL_ERROR.getMessage(), + ErrorKey.INTERNAL_ERROR.getKey(), + "An error occurred while processing the request.", + location); } - private static CreateProjectResponse error(String error, String errorKey, String message) { + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); response.setErrorKey(errorKey); response.setMessage(message); + response.setLocation(location); return response; } } \ 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 new file mode 100644 index 0000000..4ee644d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -0,0 +1,48 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ErrorKey { + + OK("000", "Success"), + PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND), + ACCESS_DENIED("002", ErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", "Internal error"), + INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST), + MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST), + INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST), + X2_ACCOUNT_MISSING_GROUPS("007", ErrorMessage.NOT_FOUND), + ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN), + ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST), + INVALID_LOCATION("011", ErrorMessage.BAD_REQUEST), + PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), + FORBIDDEN("015", ErrorMessage.FORBIDDEN), + DUPLICATE_RECORD("016", "Record already exists"), + COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST), + PROJECT_KEY_INVALID_FORMAT("018", "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"), + PROJECT_NAME_INVALID_FORMAT("019", "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"), + PROJECT_DESCRIPTION_INVALID_FORMAT("020", "projectDescription not met the pattern ^.{0,255}$"), + PROJECT_OWNER_INVALID_FORMAT("021", "projectOwner not met the pattern ^[a-z]{1,10}$"), + PROJECT_X2ACCOUNT_INVALID_FORMAT("022", "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"), + 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"); + + private String key; + private String message; + + ErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return this.key; + } + + public String getMessage() { + return this.message; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java new file mode 100644 index 0000000..767ab2e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ErrorMessage { + + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String BAD_REQUEST = "Bad Request"; + + private ErrorMessage() { + // prevent instantiation + } +} 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 9d29f13..b30ad4f 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 @@ -10,5 +10,5 @@ public interface ProjectsFacade { CreateProjectResponse createProject(CreateProjectRequest request) throws ProjectCreationException, ProjectKeyGenerationException; - CreateProjectResponse getProject(String projectKey) throws ProjectCreationException; + 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 274ad1d..ea66ee8 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 @@ -29,7 +29,7 @@ public CreateProjectResponse createProject(CreateProjectRequest request) } @Override - public CreateProjectResponse getProject(String projectKey) throws ProjectCreationException { + public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } } \ 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 f9b24c8..181b571 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 @@ -1,15 +1,49 @@ package org.opendevstack.apiservice.project.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.text.MessageFormat; @Mapper(componentModel = "spring") public interface ProjectMapper { - - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest toServiceRequest( - CreateProjectRequest apiRequest); - - CreateProjectResponse toApiResponse( - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse); + + ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") + @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") + CreateProjectResponse toApiResponse(ProjectResponse serviceResponse); + + @Named("mapStatus") + default String mapStatus(Status status) { + if (status == null) { + return null; + } + return status.getDbValue(); + } + + @Named("mapErrorDescription") + default String mapErrorDescription(ProjectResponse serviceResponse) { + return (serviceResponse.getStatus() == Status.FAILED) + ? MessageFormat.format( + "There was an error when creating the project {0}.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", serviceResponse.getProjectKey()) + : null; + } + + @Named("mapLocation") + default String mapLocation(String projectKey) { + if (projectKey == null || projectKey.isEmpty()) { + return null; + } + return "/api/pub/v0/projects/" + projectKey; + } } 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 39f5fd9..5e57b72 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 @@ -51,6 +51,9 @@ void createProject_whenSuccess_thenReturnOk() throws Exception { assertThat(result.getBody()).isNotNull(); assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -65,9 +68,12 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("CONFLICT"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_ALREADY_EXISTS"); - assertThat(result.getBody().getMessage()).contains("already exists"); + 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(); } @Test @@ -81,9 +87,12 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); + 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(); } @Test @@ -99,6 +108,8 @@ void getProject_whenFound_thenReturnOk() throws Exception { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isNotNull(); assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isNull(); verify(projectsFacade).getProject("PROJ01"); } @@ -110,23 +121,29 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("NOT_FOUND"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_NOT_FOUND"); + assertThat(result.getBody().getError()).isEqualTo("Not Found"); + assertThat(result.getBody().getErrorKey()).isEqualTo("012"); assertThat(result.getBody().getMessage()).contains("UNKNOWN"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { when(projectsFacade.getProject(anyString())) - .thenThrow(new ProjectCreationException("Database error")); + .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("INTERNAL_ERROR"); + 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(); } } 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 dc0cf00..c11a596 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 @@ -9,6 +9,9 @@ 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.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 static org.assertj.core.api.Assertions.assertThat; @@ -35,29 +38,29 @@ void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception CreateProjectRequest request = new CreateProjectRequest("My Project"); request.setProjectKey("PROJ01"); - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = - new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + ProjectResponse serviceResponse = + new ProjectResponse(); serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus("Initiated"); + serviceResponse.setStatus(Status.PENDING); when(projectService.createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + ProjectRequest.class))) .thenReturn(serviceResponse); CreateProjectResponse response = sut.createProject(request); assertThat(response).isNotNull(); assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Initiated"); + assertThat(response.getStatus()).isEqualTo("Pending"); verify(projectService).createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class)); + ProjectRequest.class)); } @Test void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { CreateProjectRequest request = new CreateProjectRequest("My Project"); when(projectService.createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + ProjectRequest.class))) .thenReturn(null); CreateProjectResponse response = sut.createProject(request); @@ -67,10 +70,10 @@ void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { @Test void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = - new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + ProjectResponse serviceResponse = + new ProjectResponse(); serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus("Found"); + serviceResponse.setStatus(Status.RUNNING); when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); @@ -78,7 +81,7 @@ void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { assertThat(response).isNotNull(); assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Found"); + assertThat(response.getStatus()).isEqualTo("Running"); } @Test 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 new file mode 100644 index 0000000..b7cf95a --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -0,0 +1,207 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProjectMapperTest { + + private ProjectMapper projectMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectMapper = Mappers.getMapper(ProjectMapper.class); + } + + @Test + void to_service_request_maps_all_fields_correctly() { + CreateProjectRequest apiRequest = new CreateProjectRequest("My Project"); + 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("My Project", result.getProjectName()); + assertEquals("A test project", result.getProjectDescription()); + } + + @Test + void to_service_request_returns_null_when_input_is_null() { + CreateProjectRequest apiRequest = null; + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + 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() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .projectFlavor("AMP") + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Pending", result.getStatus()); + assertEquals("AMP", result.getProjectFlavor()); + assertEquals("/api/pub/v0/projects/PROJ01", result.getLocation()); + } + + @Test + void to_api_response_returns_null_when_input_is_null() { + ProjectResponse serviceResponse = null; + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNull(result); + } + + @Test + void to_api_response_maps_with_null_status() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ02") + .status(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ02", result.getProjectKey()); + assertNull(result.getStatus()); + assertEquals("/api/pub/v0/projects/PROJ02", result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_null() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey(null) + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getProjectKey()); + assertEquals("Running", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_empty() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("", result.getProjectKey()); + assertEquals("Failed", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_maps_status_running_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("RUN01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Running", result.getStatus()); + } + + @Test + void to_api_response_maps_status_failed_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Failed", result.getStatus()); + } + + @Test + void to_api_response_maps_null_project_flavor() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ03") + .status(Status.PENDING) + .projectFlavor(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ03", result.getProjectKey()); + assertNull(result.getProjectFlavor()); + } + + @Test + void to_api_response_sets_error_description_when_status_is_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals( + "There was an error when creating the project FAIL01.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", + result.getErrorDescription() + ); + } + + @Test + void to_api_response_sets_null_error_description_when_status_is_not_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getErrorDescription()); + } +} diff --git a/core/pom.xml b/core/pom.xml index 6705b44..c3aa14b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -41,6 +41,14 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-devtools + runtime + true + + org.opendevstack.apiservice @@ -119,6 +127,13 @@ ${project.version} + + + org.opendevstack.apiservice + service-projects + ${project.version} + + org.opendevstack.apiservice diff --git a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java index bcb4866..f13e57a 100644 --- a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java +++ b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication(scanBasePackages = { "org.opendevstack.apiservice" }) @EnableCaching +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") public class DevstackApiServiceApplication { public static void main(String[] args) { diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java new file mode 100644 index 0000000..da25ff0 --- /dev/null +++ b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java @@ -0,0 +1,21 @@ +package org.opendevstack.apiservice.core.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Global Jackson configuration. + * Ensures that null properties are omitted from all JSON responses. + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL); + } +} + + diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java index 635a1e7..f3299cb 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java @@ -78,7 +78,8 @@ public class ProjectEntity { private String projectFlavor; /** - * Provisioning status. Known values: {@code Failed}, {@code Pending} (null = completed). + * Provisioning status. Known values: {@code Pending}, {@code Running}, {@code Failed} + * ({@code null} = completed successfully). */ @Column(name = "status", length = 50) private String status; diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java index 68f344c..2a5dc00 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java @@ -20,26 +20,11 @@ */ @Repository public interface ProjectRepository extends JpaRepository { - - /** - * Finds a project by its unique business key regardless of soft-delete status. Use - * this when you explicitly need to handle deleted projects (e.g. restore flows). - * @param projectKey the Atlassian-style project key - * @return the matching project, or {@link Optional#empty()} if it has never existed - */ - Optional findByProjectKey(String projectKey); - - /** - * Returns all active (non-deleted) projects. - * @return list of active projects; empty list if none exist - */ + + Optional findByProjectKeyIgnoreCase(String projectKey); + List findByDeletedFalse(); - - /** - * Checks whether an active project with the given key already exists. - * @param projectKey the project key to look up - * @return {@code true} if an active project with this key exists - */ - boolean existsByProjectKey(String projectKey); + + boolean existsByProjectKeyIgnoreCase(String projectKey); } diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java index 164c08e..0360a18 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java @@ -10,8 +10,6 @@ */ @SpringBootConfiguration @EnableAutoConfiguration -@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") -@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") public class PersistenceTestApplication { } \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java index be517a6..b944e73 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java @@ -113,19 +113,17 @@ void tearDown() { } private void cleanupTestData() { - TEST_KEYS.forEach(key -> projectRepository.findByProjectKey(key).ifPresent(projectRepository::delete)); + TEST_KEYS.forEach(key -> projectRepository.findByProjectKeyIgnoreCase(key).ifPresent(projectRepository::delete)); } - // ── findByProjectKey ────────────────────────────────────────────────────── - @Nested - @DisplayName("findByProjectKey") - class FindByProjectKey { + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { @Test @DisplayName("returns active project by its key") void returnsActiveProjectByKey() { - Optional result = projectRepository.findByProjectKey("IT-ACTIVE-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-ACTIVE-01"); assertThat(result).isPresent(); assertThat(result.get().getProjectKey()).isEqualTo("IT-ACTIVE-01"); @@ -135,7 +133,7 @@ void returnsActiveProjectByKey() { @Test @DisplayName("returns soft-deleted project by its key") void returnsDeletedProjectByKey() { - Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); assertThat(result).isPresent(); assertThat(result.get().getProjectKey()).isEqualTo("IT-DELETED-01"); @@ -145,7 +143,7 @@ void returnsDeletedProjectByKey() { @Test @DisplayName("returns empty Optional for a key that has never existed") void returnsEmptyForUnknownKey() { - Optional result = projectRepository.findByProjectKey("NONEXISTENTKEY"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("NONEXISTENTKEY"); assertThat(result).isEmpty(); } @@ -153,7 +151,7 @@ void returnsEmptyForUnknownKey() { @Test @DisplayName("returned entity contains all persisted fields") void returnedEntityContainsAllFields() { - Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); assertThat(result).isPresent(); ProjectEntity entity = result.get(); @@ -164,8 +162,6 @@ void returnedEntityContainsAllFields() { } } - // ── findByDeletedFalse ──────────────────────────────────────────────────── - @Nested @DisplayName("findByDeletedFalse") class FindByDeletedFalse { @@ -212,24 +208,22 @@ class ExistsByProjectKey { @Test @DisplayName("returns true for an existing active project key") void returnsTrueForActiveKey() { - assertThat(projectRepository.existsByProjectKey("IT-ACTIVE-01")).isTrue(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-ACTIVE-01")).isTrue(); } @Test @DisplayName("returns true for a soft-deleted project key") void returnsTrueForDeletedKey() { - assertThat(projectRepository.existsByProjectKey("IT-DELETED-01")).isTrue(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-DELETED-01")).isTrue(); } @Test @DisplayName("returns false for a key that has never existed") void returnsFalseForNonExistentKey() { - assertThat(projectRepository.existsByProjectKey("NONEXISTENTKEY")).isFalse(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("NONEXISTENTKEY")).isFalse(); } } - - // ── Persistence lifecycle ───────────────────────────────────────────────── - + @Nested @DisplayName("Persistence lifecycle") class PersistenceLifecycle { diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java index e067117..ca30d90 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java @@ -123,24 +123,22 @@ void save_duplicateProjectKey_throwsException() { } } - - // ── findByProjectKey ────────────────────────────────────────────────────── - + @Nested - @DisplayName("findByProjectKey") - class FindByProjectKey { + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { @Test @DisplayName("returns project regardless of deleted flag") void returnsProjectRegardlessOfDeletedFlag() { - assertThat(repository.findByProjectKey("ACTIVE-01")).isPresent(); - assertThat(repository.findByProjectKey("DELETED-01")).isPresent(); + assertThat(repository.findByProjectKeyIgnoreCase("ACTIVE-01")).isPresent(); + assertThat(repository.findByProjectKeyIgnoreCase("DELETED-01")).isPresent(); } @Test @DisplayName("returns empty for unknown key") void returnsEmptyForUnknownKey() { - assertThat(repository.findByProjectKey("UNKNOWN")).isEmpty(); + assertThat(repository.findByProjectKeyIgnoreCase("UNKNOWN")).isEmpty(); } } diff --git a/persistence/src/test/resources/application.properties b/persistence/src/test/resources/application.properties new file mode 100644 index 0000000..bee83c5 --- /dev/null +++ b/persistence/src/test/resources/application.properties @@ -0,0 +1,18 @@ +# ── HikariCP tuning for Testcontainers ───────────────────────────────────────── +# +# When the @Container PostgreSQL instance stops (after the test class finishes), +# HikariCP's background keepalive thread tries to validate open connections and +# logs "Failed to validate connection" warnings. These settings eliminate those +# warnings and make the pool give up quickly on unreachable connections during +# teardown — they have no effect on test correctness. + +# Disable background keepalive probes entirely (default: 0 = disabled already, +# but some Spring Boot auto-config versions set it higher). +spring.datasource.hikari.keepalive-time=0 + +# Reduce how long HikariCP waits to acquire a connection (default: 30 000 ms). +# During teardown this prevents the pool from blocking on a dead container. +spring.datasource.hikari.connection-timeout=3000 + +# Reduce how long HikariCP waits to validate a connection (default: 5 000 ms). +spring.datasource.hikari.validation-timeout=1000 diff --git a/pom.xml b/pom.xml index c825aa0..6076af2 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 0.0.47 0.2.7 3.6.1 + 1.6.3 diff --git a/service-projects/pom.xml b/service-projects/pom.xml index cd1a931..2a82727 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -35,6 +35,18 @@ ${jackson-databind-nullable.version} + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + org.opendevstack.apiservice external-service-api @@ -71,5 +83,28 @@ test + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + 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 new file mode 100644 index 0000000..18a6491 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.serviceproject.mapper; + +import java.util.Arrays; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +@Mapper(componentModel = "spring") +public interface ProjectResponseMapper { + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + ProjectResponse toCreateProjectResponse(ProjectEntity entity); + + @Named("mapStatus") + default Status mapStatus(String value) { + if (value == null) { + return null; + } + + return Arrays.stream(Status.values()) + .filter(s -> s.getDbValue().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown Status db value: '" + value + "'")); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java similarity index 90% rename from service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java rename to service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index 22122b6..186ffaa 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -7,7 +7,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class CreateProjectRequest { +public class ProjectRequest { private String projectKey; diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java similarity index 57% rename from service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java rename to service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java index 1dcd607..40ee62a 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -9,17 +9,11 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class CreateProjectResponse { +public class ProjectResponse { private String projectKey; - private String status; + private Status status; - private String message; - - private String error; - - private String errorKey; - - private String errorDescription; + private String projectFlavor; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java new file mode 100644 index 0000000..960efc6 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Status { + + PENDING("Pending"), + RUNNING("Running"), + FAILED("Failed"); + + private final String dbValue; +} 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 63f2d66..c2abcc6 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 @@ -1,12 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; public interface ProjectService { - CreateProjectResponse createProject(CreateProjectRequest request); + ProjectResponse createProject(ProjectRequest request); - CreateProjectResponse getProject(String projectKey); + 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 3e8caab..1d8d7b6 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 @@ -5,12 +5,17 @@ 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.model.CreateProjectRequest; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +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.service.ProjectService; import org.springframework.stereotype.Service; +import java.util.Optional; + @Service @Slf4j @AllArgsConstructor @@ -23,15 +28,25 @@ public class ProjectServiceImpl implements ProjectService { private final JiraService jiraService; private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectRepository projectRepository; + + private final ProjectResponseMapper projectResponseMapper; @Override - public CreateProjectResponse createProject(CreateProjectRequest request) { - return CreateProjectResponse.builder().build(); + public ProjectResponse createProject(ProjectRequest request) { + return ProjectResponse.builder().build(); } @Override - public CreateProjectResponse getProject(String projectKey) { - return CreateProjectResponse.builder().build(); + public ProjectResponse getProject(String projectKey) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + return projectResponseMapper.toCreateProjectResponse(project.get()); + } + + return null; } } 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 new file mode 100644 index 0000000..827ba41 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -0,0 +1,192 @@ +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.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; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectServiceImplTest { + + @Mock + private OpenshiftService openshiftService; + + @Mock + private BitbucketService bitbucketService; + + @Mock + private JiraService jiraService; + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectRepository projectRepository; + + @Mock + private ProjectResponseMapper projectResponseMapper; + + private ProjectServiceImpl projectService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectService = new ProjectServiceImpl( + openshiftService, + bitbucketService, + jiraService, + generateProjectKeyService, + projectRepository, + projectResponseMapper + ); + } + + @Test + void get_project_returns_response_when_project_exists() { + + String projectKey = "MY-PROJECT"; + UUID projectId = UUID.randomUUID(); + + ProjectEntity projectEntity = ProjectEntity.builder() + .id(projectId) + .projectKey(projectKey) + .projectName("My Project") + .description("Test project") + .configurationItem("CI-123") + .location("eu") + .projectFlavor("AMP") + .status("Completed") + .deleted(false) + .ldapGroupManager("cn=my-project-manager,ou=groups,dc=example,dc=com") + .ldapGroupTeam("cn=my-project-team,ou=groups,dc=example,dc=com") + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + 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()); + assertEquals(Status.RUNNING, result.getStatus()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + verify(projectResponseMapper).toCreateProjectResponse(projectEntity); + } + + @Test + 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() { + + String projectKey = "ERROR-PROJECT"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("Database connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProject(projectKey)); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } + + @Test + 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); + } + + @Test + void get_project_returns_response_for_soft_deleted_project() { + + String projectKey = "DELETED-PROJECT"; + + ProjectEntity deletedEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("Deleted Project") + .configurationItem("CI-456") + .location("eu") + .deleted(true) + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(deletedEntity)); + when(projectResponseMapper.toCreateProjectResponse(deletedEntity)).thenReturn(expectedResponse); + + + ProjectResponse result = projectService.getProject(projectKey); + + + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } +} \ No newline at end of file