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/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java new file mode 100644 index 0000000..2ee32d5 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java @@ -0,0 +1,87 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = ProjectControllerIntegrationTest.TestConfig.class) +@AutoConfigureMockMvc(addFilters = false) +class ProjectControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ProjectsFacade facade; + + @Test + void createProject_withProvidedProjectKey_returnsInitiatedResponse() throws Exception { + CreateProjectResponse serviceResponse = + new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + when(facade.createProject(any(CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + String payload = """ + { + \"projectKey\": \"PROJ01\", + \"projectName\": \"My Project\", + \"projectDescription\": \"desc\" + } + """; + + mockMvc.perform(post("/api/pub/v0/projects") + .contentType("application/json") + .content(payload == null ? "" : payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.projectKey").value("PROJ01")) + .andExpect(jsonPath("$.status").value("Initiated")) + .andExpect(jsonPath("$.error").doesNotExist()) + .andExpect(jsonPath("$.errorKey").doesNotExist()) + .andExpect(jsonPath("$.errorDescription").doesNotExist()); + } + + @Test + void getProject_whenNotFound_returns404() throws Exception { + when(facade.getProject("UNKNOWN")).thenReturn(null); + + mockMvc.perform(get("/api/pub/v0/projects/UNKNOWN")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.errorKey").value("PROJECT_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Project with key 'UNKNOWN' not found")) + .andExpect(jsonPath("$.projectKey").doesNotExist()) + .andExpect(jsonPath("$.status").doesNotExist()) + .andExpect(jsonPath("$.errorDescription").doesNotExist()); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) + @Import({ProjectController.class}) + static class TestConfig { + } +} \ No newline at end of file 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..9e73084 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 @@ -68,6 +71,9 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce assertThat(result.getBody().getError()).isEqualTo("CONFLICT"); assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_ALREADY_EXISTS"); assertThat(result.getBody().getMessage()).contains("already exists"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -84,6 +90,9 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr 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"); } @@ -113,6 +124,9 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception { assertThat(result.getBody().getError()).isEqualTo("NOT_FOUND"); assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_NOT_FOUND"); assertThat(result.getBody().getMessage()).contains("UNKNOWN"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -127,6 +141,9 @@ void getProject_whenServiceThrows_thenReturnInternalServerError() throws Excepti assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); assertThat(result.getBody().getErrorKey()).isEqualTo("INTERNAL_ERROR"); 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/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/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/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/CreateProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/CreateProjectResponseMapper.java new file mode 100644 index 0000000..7f187fd --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/CreateProjectResponseMapper.java @@ -0,0 +1,16 @@ +package org.opendevstack.apiservice.serviceproject.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; + +@Mapper(componentModel = "spring") +public interface CreateProjectResponseMapper { + + @Mapping(target = "message", ignore = true) + @Mapping(target = "error", ignore = true) + @Mapping(target = "errorKey", ignore = true) + @Mapping(target = "errorDescription", ignore = true) + CreateProjectResponse toCreateProjectResponse(ProjectEntity entity); +} 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..bee32a8 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.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.opendevstack.apiservice.serviceproject.mapper.CreateProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; 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,6 +28,10 @@ public class ProjectServiceImpl implements ProjectService { private final JiraService jiraService; private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectRepository projectRepository; + + private final CreateProjectResponseMapper createProjectResponseMapper; @Override public CreateProjectResponse createProject(CreateProjectRequest request) { @@ -31,7 +40,13 @@ public CreateProjectResponse createProject(CreateProjectRequest request) { @Override public CreateProjectResponse getProject(String projectKey) { - return CreateProjectResponse.builder().build(); + Optional project = projectRepository.findByProjectKey(projectKey); + + if (project.isPresent()) { + return createProjectResponseMapper.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..e9db6ae --- /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.CreateProjectResponseMapper; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +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 CreateProjectResponseMapper createProjectResponseMapper; + + private ProjectServiceImpl projectService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectService = new ProjectServiceImpl( + openshiftService, + bitbucketService, + jiraService, + generateProjectKeyService, + projectRepository, + createProjectResponseMapper + ); + } + + @Test + void get_project_returns_response_when_project_exists() { + // GIVEN + 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(); + + CreateProjectResponse expectedResponse = CreateProjectResponse.builder() + .projectKey(projectKey) + .status("Completed") + .build(); + + when(projectRepository.findByProjectKey(projectKey)).thenReturn(Optional.of(projectEntity)); + when(createProjectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); + + // WHEN + CreateProjectResponse result = projectService.getProject(projectKey); + + // THEN + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + assertEquals("Completed", result.getStatus()); + verify(projectRepository).findByProjectKey(projectKey); + verify(createProjectResponseMapper).toCreateProjectResponse(projectEntity); + } + + @Test + void get_project_returns_null_when_project_does_not_exist() { + // GIVEN + String projectKey = "NON-EXISTING"; + + when(projectRepository.findByProjectKey(projectKey)).thenReturn(Optional.empty()); + + // WHEN + CreateProjectResponse result = projectService.getProject(projectKey); + + // THEN + assertNull(result); + verify(projectRepository).findByProjectKey(projectKey); + verify(createProjectResponseMapper, never()).toCreateProjectResponse(any()); + } + + @Test + void create_project_returns_empty_response() { + // GIVEN + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey("NEW-PROJECT"); + request.setProjectKeyPattern("NEW%06d"); + request.setProjectName("New Project"); + request.setProjectDescription("New test project"); + + // WHEN + CreateProjectResponse result = projectService.createProject(request); + + // THEN + assertNotNull(result); + assertNull(result.getProjectKey()); + } + + @Test + void get_project_propagates_repository_exception() { + // GIVEN + String projectKey = "ERROR-PROJECT"; + + when(projectRepository.findByProjectKey(projectKey)) + .thenThrow(new RuntimeException("Database connection error")); + + // WHEN / THEN + assertThrows(RuntimeException.class, () -> projectService.getProject(projectKey)); + verify(projectRepository).findByProjectKey(projectKey); + } + + @Test + void get_project_returns_null_when_project_key_is_null() { + // GIVEN + when(projectRepository.findByProjectKey(null)).thenReturn(Optional.empty()); + + // WHEN + CreateProjectResponse result = projectService.getProject(null); + + // THEN + assertNull(result); + verify(projectRepository).findByProjectKey(null); + } + + @Test + void get_project_returns_response_for_soft_deleted_project() { + // GIVEN + String projectKey = "DELETED-PROJECT"; + + ProjectEntity deletedEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("Deleted Project") + .configurationItem("CI-456") + .location("eu") + .deleted(true) + .build(); + + CreateProjectResponse expectedResponse = CreateProjectResponse.builder() + .projectKey(projectKey) + .status("Deleted") + .build(); + + when(projectRepository.findByProjectKey(projectKey)).thenReturn(Optional.of(deletedEntity)); + when(createProjectResponseMapper.toCreateProjectResponse(deletedEntity)).thenReturn(expectedResponse); + + // WHEN + CreateProjectResponse result = projectService.getProject(projectKey); + + // THEN + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + verify(projectRepository).findByProjectKey(projectKey); + } +} \ No newline at end of file