diff --git a/api-project/openapi/api-project-internal.yaml b/api-project/openapi/api-project-internal.yaml new file mode 100644 index 00000000..74a1bb1b --- /dev/null +++ b/api-project/openapi/api-project-internal.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects Internal + description: API for manage EDP projects with extended methods +paths: + /projects/{projectKey}: + patch: + tags: + - Projects Internal + summary: Updates projects by project key. + description: Updates the current status and details of the project identified by the given project key. + operationId: updateProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectRequest' + responses: + '204': + description: Project information updated successfully or no project found for the provided projectKey. + "400": + description: Bad Request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + '500': + description: Internal server error. +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + UpdateProjectRequest: + type: object + properties: + status: + type: string + description: Status of the project. If not in the allowed list, error INVALID_STATUS (030) is returned. + additionalProperties: false \ No newline at end of file diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 9a02630d..681074b1 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -12,7 +12,7 @@ servers: default: localhost:8080 description: Development environment tags: -- name: Project +- name: Projects description: API for manage EDP projects. paths: /projects: diff --git a/api-project/pom.xml b/api-project/pom.xml index 7172641e..799043f6 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -168,6 +168,35 @@ + + generate-api-project-extended + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-internal.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + 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 54c33275..beea15b4 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 { @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { - projectRequestValidator.validate(createProjectRequest); + projectRequestValidator.validateCreateRequest(createProjectRequest); UUID clientId = JwtUtils.getClientId(); CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java new file mode 100644 index 00000000..af68367e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java @@ -0,0 +1,50 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.project.api.ProjectsInternalApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping(ProjectInternalController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectInternalController implements ProjectsInternalApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + private final ProjectRequestValidator projectRequestValidator; + + @PatchMapping("/{projectKey}") + @Override + public ResponseEntity updateProject(@PathVariable String projectKey, + @Valid @RequestBody UpdateProjectRequest updateProjectRequest) { + String location = API_BASE_PATH + "/" + projectKey; + projectRequestValidator.validateUpdateRequest(updateProjectRequest); + + projectsFacade.updateProject(projectKey, updateProjectRequest); + + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .header(HTTP_HEADER_LOCATION, location) + .build(); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java new file mode 100644 index 00000000..ab8faab9 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ProjectInternalController; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ProjectInternalController.class) +@Slf4j +public class ProjectInternalExceptionHandler { + + @ExceptionHandler(ProjectUpdateValidationException.class) + public ResponseEntity handleUpdateValidationException(ProjectUpdateValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + + +} 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 2b1718a0..a7755a78 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 @@ -31,7 +31,8 @@ public enum ErrorKey { PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", ErrorMessage.PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS), CLIENT_APP_NOT_REGISTERED("027", ErrorMessage.CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED), INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), - INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST), + INVALID_STATUS("030", ErrorMessage.INVALID_STATUS); private String key; private String 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 index 5547bbdd..8c8ed276 100644 --- 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 @@ -9,6 +9,7 @@ public class ErrorMessage { public static final String SUCCESS = "Success"; public static final String INVALID_LOCATION = "Incorrect location. Valid locations are:"; + public static final String INVALID_STATUS = "Incorrect status. Valid status are:"; public static final String RECORD_ALREADY_EXISTS = "Record already exists"; public static final String PROJECT_KEY_NOT_MET_THE_PATTERN = "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"; public static final String PROJECT_NAME_NOT_MET_THE_PATTERN = "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java new file mode 100644 index 00000000..56f0b5b2 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectUpdateValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectUpdateValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } + + public ProjectUpdateValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } +} 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 f8b0112e..f36aa8dc 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 @@ -2,6 +2,7 @@ import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import java.util.UUID; @@ -10,4 +11,6 @@ public interface ProjectsFacade { CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); CreateProjectResponse getProject(String projectKey); + + void updateProject(String projectKey, UpdateProjectRequest request); } \ 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 8e3b80d6..afa845df 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 @@ -11,6 +11,7 @@ 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.model.UpdateProjectRequest; import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; @@ -84,6 +85,11 @@ public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } + @Override + public void updateProject(String projectKey, UpdateProjectRequest request) { + projectService.updateProjectStatus(projectKey, request.getStatus()); + } + private ProjectResponse registerProject(ProjectCreationCommand command) { ProjectRequest projectRequest = projectMapper.toServiceRequest(command); 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 f3ea98e7..c96d32f9 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 @@ -1,11 +1,15 @@ package org.opendevstack.apiservice.project.validation; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Arrays; import java.util.List; @Component @@ -14,11 +18,15 @@ public class ProjectRequestValidator { @Value("${apis.projects.locations}") private List locations; - public void validate(CreateProjectRequest request) { + public void validateCreateRequest(CreateProjectRequest request) { validateFlavorOrConfigItem(request); validateOwnerIfX2AccountIsPresent(request); validateLocation(request); } + + public void validateUpdateRequest(UpdateProjectRequest request) { + validateStatus(request); + } private void validateFlavorOrConfigItem(CreateProjectRequest request) { String projectFlavor = request.getProjectFlavor(); @@ -60,4 +68,20 @@ private void validateLocation(CreateProjectRequest request) { String.join(", ", locations)); } } + + private void validateStatus(UpdateProjectRequest request) { + String status = request.getStatus(); + + if (status == null || status.trim().isEmpty()) { + return; + } + + List statusList = Arrays.stream(Status.values()) + .map(e -> e.getDbValue()) + .toList(); + if (!statusList.contains(status)) { + throw new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, + String.join(", ", statusList)); + } + } } 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 d184ef9c..2e1d53f8 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 @@ -75,7 +75,7 @@ void create_project_returns_ok_when_creation_succeeds() { assertThat(result.getBody().getError()).isNull(); assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); + verify(projectRequestValidator).validateCreateRequest(request); verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java new file mode 100644 index 00000000..efcfc848 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java @@ -0,0 +1,86 @@ +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.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectInternalControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + @Mock + private ProjectRequestValidator projectRequestValidator; + + private ProjectInternalController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void update_project_returns_no_content() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + + ResponseEntity result = sut.updateProject("PROJ01", request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(result.getBody()).isNull(); + assertThat(result.getHeaders().getFirst("Location")) + .isEqualTo(ProjectInternalController.API_BASE_PATH + "/PROJ01"); + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade).updateProject("PROJ01", request); + } + + @Test + void update_project_propagates_validation_exception_when_validator_throws() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + doThrow(new ProjectValidationException(ErrorKey.INVALID_STATUS)) + .when(projectRequestValidator).validateUpdateRequest(request); + + assertThrows(ProjectValidationException.class, () -> sut.updateProject("PROJ01", request)); + + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade, never()).updateProject("PROJ01", request); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java new file mode 100644 index 00000000..968c7670 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java @@ -0,0 +1,53 @@ +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.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ProjectInternalExceptionHandlerTest { + + private ProjectInternalExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_update_validation_exception_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + + @Test + void handle_update_validation_exception_with_additional_message_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, "Pending,Running,Failed"); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + +} 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 a81646cc..abf00d34 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 @@ -14,6 +14,7 @@ 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.model.UpdateProjectRequest; import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; @@ -208,4 +209,20 @@ void get_project_returns_null_when_service_returns_null() { assertNull(result); } + + @Test + void update_project_calls_update_status() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .build(); + + when(projectService.getProject("PROJ01")).thenReturn(projectResponse); + + sut.updateProject("PROJ01", request); + + verify(projectService).updateProjectStatus("PROJ01", "Running"); + } } 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 596f39cd..e2fed8c2 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 @@ -5,8 +5,10 @@ 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.ProjectUpdateValidationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import java.lang.reflect.Field; import java.util.List; @@ -30,7 +32,7 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { } @Test - void validate_throws_exception_when_project_flavor_and_config_item_both_null() { + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_null() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor(null); @@ -38,14 +40,14 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_null() { ProjectValidationException exception = assertThrows( ProjectValidationException.class, - () -> sut.validate(request) + () -> sut.validateCreateRequest(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); } @Test - void validate_throws_exception_when_project_flavor_and_config_item_both_empty() { + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_empty() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor(""); @@ -53,7 +55,7 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() ProjectValidationException exception = assertThrows( ProjectValidationException.class, - () -> sut.validate(request) + () -> sut.validateCreateRequest(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -65,17 +67,17 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() "null, JIRA", "STANDARD, JIRA" }) - void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { + void validateCreateRequest_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_throws_exception_when_x2account_present_but_owner_missing() { + void validateCreateRequest_throws_exception_when_x2account_present_but_owner_missing() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -83,7 +85,7 @@ void validate_throws_exception_when_x2account_present_but_owner_missing() { request.setOwner(null); ProjectValidationException exception = - assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); assertEquals(ErrorKey.MANDATORY_OWNER, exception.getErrorKey()); } @@ -95,7 +97,7 @@ void validate_throws_exception_when_x2account_present_but_owner_missing() { "null, owner1", "x2valid, ownerX" }) - void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { + void validateCreateRequest_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -103,27 +105,27 @@ void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String request.setX2OdsAccount("null".equals(x2) ? null : x2); request.setOwner(owner); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_succeeds_when_location_is_null_or_empty() { + void validateCreateRequest_succeeds_when_location_is_null_or_empty() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); request.setLocation(null); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); request.setLocation(""); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); request.setLocation(" "); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_throws_exception_when_location_not_in_allowed_list() { + void validateCreateRequest_throws_exception_when_location_not_in_allowed_list() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -131,7 +133,7 @@ void validate_throws_exception_when_location_not_in_allowed_list() { request.setLocation("INVALID_LOCATION_NOT_ALLOWED"); ProjectValidationException exception = - assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); assertEquals(ErrorKey.INVALID_LOCATION, exception.getErrorKey()); } @@ -142,12 +144,60 @@ void validate_throws_exception_when_location_not_in_allowed_list() { "BARCELONA", "SANT_CUGAT" }) - void validate_succeeds_when_location_is_allowed(String location) { + void validateCreateRequest_succeeds_when_location_is_allowed(String location) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); request.setLocation(location); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_null() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(null); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_empty_string() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(""); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_blank() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(" "); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @ParameterizedTest + @CsvSource({ + "Pending", + "Running", + "Failed" + }) + void validateUpdateRequest_succeeds_when_status_is_valid(String status) { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(status); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_throws_exception_with_error_key_INVALID_STATUS_when_status_is_invalid() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + ProjectUpdateValidationException exception = + assertThrows(ProjectUpdateValidationException.class, () -> sut.validateUpdateRequest(request)); + + assertEquals(ErrorKey.INVALID_STATUS, exception.getErrorKey()); } } \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql new file mode 100644 index 00000000..d5daa7de --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:009-add-project-v1-api-definition +-- Description: Seed API definitions for Project API v1. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-v1', 'Project API v1', 'projects', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE) + ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-v1'); \ No newline at end of file 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 3a23a8a4..896e7c6d 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 @@ -10,6 +10,8 @@ public interface ProjectService { ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); + + void updateProjectStatus(String projectKey, String status); List findProjectsByName(String projectName); } 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 7673925b..fe9b34e3 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 @@ -62,6 +62,17 @@ public ProjectResponse getProject(String projectKey) { return null; } + @Override + public void updateProjectStatus(String projectKey, String status) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + ProjectEntity entity = project.get(); + entity.setStatus(status); + projectRepository.save(entity); + } + } + @Override public List findProjectsByName(String projectName) { List projects = projectRepository.findByProjectNameIgnoreCase(projectName); 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 cadb492f..ca30c712 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 @@ -3,6 +3,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; @@ -189,4 +190,46 @@ void find_projects_by_name_returns_empty_list_when_project_does_not_exist() { verify(projectRepository).findByProjectNameIgnoreCase(projectName); verify(projectResponseMapper).toCreateProjectResponse(anyList()); } -} \ No newline at end of file + + @Test + void update_project_status_saves_entity_with_new_status_when_project_exists() { + String projectKey = "PROJ01"; + ProjectEntity entity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("My Project") + .status("Pending") + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(entity)); + + projectService.updateProjectStatus(projectKey, "Running"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectEntity.class); + verify(projectRepository).save(captor.capture()); + assertEquals("Running", captor.getValue().getStatus()); + } + + @Test + void update_project_status_does_nothing_when_project_not_found() { + String projectKey = "UNKNOWN"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); + + projectService.updateProjectStatus(projectKey, "Running"); + + verify(projectRepository, never()).save(any(ProjectEntity.class)); + } + + @Test + void update_project_status_propagates_repository_exception() { + String projectKey = "ERROR"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, () -> projectService.updateProjectStatus(projectKey, "Running")); + + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } +}