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
+
+ 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);
+ }
+}