diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 30820a9..26f00f7 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -138,19 +138,21 @@ components: maxLength: 255 projectFlavor: type: string - description: Flavor of the project. Either projectFlavor or configurationItem must be provided. + description: Project flavor. Must be provided if configurationItem is not present. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. configurationItem: type: string - description: Configuration item for the project. Either projectFlavor or configurationItem must be provided. + description: Configuration item. Must be present if projectFlavor is not provided. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. location: type: string - description: Location of the project. + description: Location of the project. Must be one of the allowed locations. If not in the allowed list, error INVALID_LOCATION (011) is returned. x2OdsAccount: type: string description: Technical account of the project. + pattern: "^x2[a-zA-Z0-9]{0,13}$" owner: type: string - description: Owner of the project. + description: Owner of the project. If x2OdsAccount is provided but owner is missing, error MANDATORY_OWNER (024) is returned. + pattern: "^[a-z]{1,10}$" oneOf: - required: [projectFlavor] - required: [configurationItem] diff --git a/api-project/pom.xml b/api-project/pom.xml index e77eda9..3a7962e 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -33,6 +33,13 @@ org.springframework.boot spring-boot-starter-validation + + + org.springframework.boot + spring-boot-devtools + runtime + true + org.springdoc @@ -44,6 +51,12 @@ service-projects ${project.version} + + + org.opendevstack.apiservice + persistence + ${project.version} + io.jsonwebtoken diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index 89aadde..b714133 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -4,11 +4,9 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectsApi; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequestMapping(ProjectController.API_BASE_PATH) @AllArgsConstructor @@ -37,44 +37,27 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - try { - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(projectsFacade.createProject(createProjectRequest)); - } catch (ProjectCreationException e) { - log.error("Project creation conflict: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.conflict(e.getMessage(), API_BASE_PATH)); - } catch (ProjectKeyGenerationException e) { - log.error("Failed to generate project key: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); - } + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); + projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectResponse); } @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { String location = API_BASE_PATH + "/" + projectKey; - try { - CreateProjectResponse response = projectsFacade.getProject(projectKey); - if (response == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.notFound(projectKey, location)); - } - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, location) - .body(response); - } catch (Exception e) { - log.error("Unexpected error retrieving project '{}': {}", projectKey, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.internalError(location)); + .body(ProjectResponseFactory.notFound(projectKey, location)); } + return ResponseEntity.status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, location) + .body(response); } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java index b1ed5d2..caf14c9 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -39,6 +39,14 @@ public static CreateProjectResponse internalError(String location) { location); } + public static CreateProjectResponse internalError(String location, String message) { + return error( + ErrorKey.INTERNAL_ERROR.getMessage(), + ErrorKey.INTERNAL_ERROR.getKey(), + message, + location); + } + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index 6aa6db2..56308e1 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -1,8 +1,11 @@ package org.opendevstack.apiservice.project.controller.advice; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.springframework.http.HttpStatus; @@ -23,7 +26,9 @@ public class ProjectExceptionHandler { "projectName", ErrorKey.PROJECT_NAME_INVALID_FORMAT, "projectDescription", ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT, "projectFlavor", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, - "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "x2OdsAccount", ErrorKey.PROJECT_X2ACCOUNT_INVALID_FORMAT, + "owner", ErrorKey.PROJECT_OWNER_INVALID_FORMAT ); @ExceptionHandler(MethodArgumentNotValidException.class) @@ -55,6 +60,18 @@ public ResponseEntity handleMethodArgumentNotValidExcepti return ResponseEntity.badRequest().body(response); } + @ExceptionHandler(ClientAppNotRegisteredException.class) + public ResponseEntity handleClientAppNotRegisteredException( + ClientAppNotRegisteredException ex) { + log.warn("ClientApp registration error: {}", ex.getMessage()); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.FORBIDDEN.getReasonPhrase()); + response.setErrorKey(ErrorKey.CLIENT_APP_NOT_REGISTERED.getKey()); + response.setMessage(ErrorKey.CLIENT_APP_NOT_REGISTERED.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + @ExceptionHandler(ProjectValidationException.class) public ResponseEntity handleValidationException(ProjectValidationException ex) { log.warn("Validation error: {}", ex.getMessage()); @@ -66,6 +83,41 @@ public ResponseEntity handleValidationException(ProjectVa response.setMessage(errorKey.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + + @ExceptionHandler(ProjectCreationException.class) + public ResponseEntity handleProjectCreationException( + ProjectCreationException ex) { + log.error("Project creation error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(AutomationPlatformException.class) + public ResponseEntity handleAutomationPlatformException( + AutomationPlatformException ex) { + log.error("Failed to execute automated job: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java new file mode 100644 index 0000000..a3b8c75 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ClientAppNotRegisteredException extends RuntimeException { + + public ClientAppNotRegisteredException(String clientId) { + super(String.format("ClientApp with clientId '%s' is not registered", clientId)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index 4ee644d..fd7904b 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -28,7 +28,10 @@ public enum ErrorKey { BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), PROJECT_ALREADY_EXISTS("025", "Project already exists"), - PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"); + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"), + CLIENT_APP_NOT_REGISTERED("027", "ClientApp not registered, manual registration required"), + INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java index 87e12af..8189cdc 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.project.exception; -public class ProjectCreationException extends Exception { +public class ProjectCreationException extends RuntimeException { public ProjectCreationException(String message) { super(message); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java deleted file mode 100644 index be9b66a..0000000 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.opendevstack.apiservice.project.exception; - -public class ProjectKeyGenerationException extends Exception { - - public ProjectKeyGenerationException(String message) { - super(message); - } - - public ProjectKeyGenerationException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index b30ad4f..f8b0112 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,14 +1,13 @@ package org.opendevstack.apiservice.project.facade; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import java.util.UUID; + public interface ProjectsFacade { - CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException; + CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); CreateProjectResponse getProject(String projectKey); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java new file mode 100644 index 0000000..2267c0b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java @@ -0,0 +1,31 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ProjectCreationCommand { + + private String projectKey; + + private String projectName; + + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java new file mode 100644 index 0000000..10b087d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -0,0 +1,142 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ProjectCreationCommandBuilder { + + private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectExistenceService projectExistenceService; + + public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + + String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); + String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); + String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); + String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); + String location = firstNonBlank(request.getLocation(), flavor.getLocation()); + String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + String projectName = firstNonBlank(request.getProjectName(), projectKey); + String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); + + return new ProjectCreationCommand( + projectKey, + projectName, + projectDescription, + projectFlavor, + configurationItem, + location, + x2account, + owner, + clientApp.getId()); + } + + private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { + List flavors = clientApp.getProjectFlavors(); + + if (flavors == null || flavors.isEmpty()) { + log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); + throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + } + + if (Strings.isNotEmpty(request.getProjectFlavor())) { + return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); + } + + if (Strings.isNotEmpty(request.getConfigurationItem())) { + return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); + } + + throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + } + + private ClientAppProjectFlavorEntity resolveByFlavorName( + String flavorName, List flavors, UUID clientId) { + return flavors.stream() + .filter(f -> flavorName.equals(f.getName())) + .findFirst() + .orElseThrow(() -> { + log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); + return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + }); + } + + private ClientAppProjectFlavorEntity resolveByConfigurationItem( + String configurationItem, List flavors, UUID clientId) { + List matchingFlavors = flavors.stream() + .filter(f -> configurationItem.equals(f.getConfigItem())) + .toList(); + + if (matchingFlavors.size() != 1) { + log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", + configurationItem, clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); + + if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { + log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", + configurationItem, matchedFlavor.getName(), clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + return matchedFlavor; + } + + private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + try { + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFound(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectValidationException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); + } + } + + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { + String[] allowedConfigItems = flavor.getAllowedConfigItems(); + + if (allowedConfigItems == null || allowedConfigItems.length == 0) { + return true; + } + + return Arrays.asList(allowedConfigItems).contains(configurationItem); + } + + private String firstNonBlank(String preferred, String fallback) { + return Strings.isNotEmpty(preferred) ? preferred : fallback; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index ea66ee8..87a9046 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,31 +1,92 @@ package org.opendevstack.apiservice.project.facade.impl; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Map; +import java.util.UUID; + @Component("apiProjectFacadeImpl") +@Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { + @Value("${apis.projects.ansible-workflow-name}") + private String createProjectWorkflow; + private final ProjectService projectService; + private final ProjectMapper projectMapper; + + private final AutomationParametersMapper automationParametersMapper; + + private final ProjectCreationResponseMapper projectCreationResponseMapper; + + private final ProjectCreationCommandBuilder projectCreationCommandBuilder; + + private final ClientAppService clientAppService; + + private final AutomationPlatformService automationPlatformService; public ProjectsFacadeImpl( ProjectService projectService, - ProjectMapper projectMapper) { + ProjectMapper projectMapper, + AutomationParametersMapper automationParametersMapper, + ProjectCreationResponseMapper projectCreationResponseMapper, + ProjectCreationCommandBuilder projectCreationCommandBuilder, + ClientAppService clientAppService, + AutomationPlatformService automationPlatformService) { this.projectService = projectService; this.projectMapper = projectMapper; + this.automationParametersMapper = automationParametersMapper; + this.projectCreationResponseMapper = projectCreationResponseMapper; + this.projectCreationCommandBuilder = projectCreationCommandBuilder; + this.clientAppService = clientAppService; + this.automationPlatformService = automationPlatformService; } @Override - public CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException { - return projectMapper.toApiResponse(projectService.createProject(projectMapper.toServiceRequest(request))); + public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) { + + ClientAppEntity clientApp = clientAppService.findByClientId(clientId); + + ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); + + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.PENDING); + + ProjectResponse project = projectService.saveProject(projectRequest); + + String projectId = project.getProjectId().toString(); + Map workflowParameters = automationParametersMapper.toWorkflowParameters(command, projectId); + + AutomationExecutionResult automationExecutionResult = automationPlatformService + .executeWorkflow(createProjectWorkflow, workflowParameters); + + if (automationExecutionResult.isSuccessful()) { + return projectCreationResponseMapper.toSuccessResponse(command, project); + } else { + projectRequest.setProjectId(project.getProjectId()); + projectRequest.setStatus(Status.FAILED); + projectService.saveProject(projectRequest); + throw new ProjectCreationException("Failed to create project: " + + automationExecutionResult.getErrorDetails()); + } } @Override diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java new file mode 100644 index 0000000..fa1ea7e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; + +import java.util.HashMap; +import java.util.Map; + +@Mapper(componentModel = "spring") +public interface AutomationParametersMapper { + + default Map toWorkflowParameters(ProjectCreationCommand command, String projectId) { + Map parameters = new HashMap<>(); + parameters.put("geographic_region", command.getLocation()); + parameters.put("project_flavor", command.getProjectFlavor()); + parameters.put("project_owner", command.getOwner()); + parameters.put("project_id", projectId); + parameters.put("configuration_item", command.getConfigurationItem()); + parameters.put("project_key", command.getProjectKey()); + parameters.put("special_account", command.getX2OdsAccount()); + parameters.put("description", command.getProjectDescription()); + parameters.put("project_name", command.getProjectName()); + parameters.put("client_id", command.getClientId().toString()); + return parameters; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java new file mode 100644 index 0000000..0eb9a53 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectCreationResponseMapper { + + @Mapping(target = "message", constant = "The project creation process has been successfully initiated.") + @Mapping(target = "status", ignore = true) + @Mapping(target = "httStatus", constant = "OK") + @Mapping(target = "errorKey", constant = "000") + @Mapping(target = "projectKey", source = "project.projectKey") + @Mapping(target = "projectFlavor", ignore = true) + @Mapping(target = "location", ignore = true) + @Mapping(target = "error", ignore = true) + @Mapping(target = "errorDescription", ignore = true) + CreateProjectResponse toSuccessResponse(ProjectCreationCommand command, ProjectResponse project); +} + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java index 181b571..adb2032 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; @@ -16,6 +17,8 @@ public interface ProjectMapper { ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + ProjectRequest toServiceRequest(ProjectCreationCommand command); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java new file mode 100644 index 0000000..8c15318 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.service; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClientAppService { + + private final ClientAppRepository clientAppRepository; + + @Transactional(readOnly = true) + public ClientAppEntity findByClientId(UUID clientId) { + log.debug("Looking up ClientApp for clientId={}", clientId); + return clientAppRepository.findDetailedByClientId(clientId) + .orElseThrow(() -> { + log.warn("ClientApp not found for clientId={}", clientId); + return new ClientAppNotRegisteredException(clientId.toString()); + }); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java index a964261..effe572 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 @@ -3,13 +3,21 @@ import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class ProjectRequestValidator { + @Value("${apis.projects.locations}") + private List locations; + public void validate(CreateProjectRequest request) { validateFlavorOrConfigItem(request); + validateOwnerIfX2AccountIsPresent(request); + validateLocation(request); } private void validateFlavorOrConfigItem(CreateProjectRequest request) { @@ -25,4 +33,30 @@ private void validateFlavorOrConfigItem(CreateProjectRequest request) { ); } } + + private void validateOwnerIfX2AccountIsPresent(CreateProjectRequest request) { + String x2Account = request.getX2OdsAccount(); + String owner = request.getOwner(); + + boolean hasX2Account = x2Account != null && !x2Account.trim().isEmpty(); + boolean hasOwner = owner != null && !owner.trim().isEmpty(); + + if(hasX2Account && !hasOwner) { + throw new ProjectValidationException( + ErrorKey.MANDATORY_OWNER + ); + } + } + + private void validateLocation(CreateProjectRequest request) { + String location = request.getLocation(); + + if (location == null || location.trim().isEmpty()) { + return; + } + + if (!locations.contains(location)) { + throw new ProjectValidationException(ErrorKey.INVALID_LOCATION); + } + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index f40526b..fbd1123 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -16,10 +14,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; + class ProjectControllerTest { @Mock @@ -43,16 +42,17 @@ void tearDown() throws Exception { } @Test - void create_project_returns_ok_when_creation_succeeds() throws Exception { + void create_project_returns_ok_when_creation_succeeds() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); CreateProjectResponse serviceResponse = new CreateProjectResponse(); serviceResponse.setProjectKey("PROJ01"); serviceResponse.setStatus("Initiated"); + serviceResponse.setErrorKey("000"); serviceResponse.setMessage("The project creation process has been successfully initiated."); - when(projectsFacade.createProject(any(CreateProjectRequest.class))) + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) .thenReturn(serviceResponse); ResponseEntity result = sut.createProject(request); @@ -62,50 +62,10 @@ void create_project_returns_ok_when_creation_succeeds() throws Exception { 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(); - verify(projectRequestValidator).validate(request); - } - - @Test - void create_project_returns_conflict_when_project_creation_exception_is_thrown() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey("EXISTING"); - - when(projectsFacade.createProject(any(CreateProjectRequest.class))) - .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Project already exists"); - assertThat(result.getBody().getErrorKey()).isEqualTo("025"); - assertThat(result.getBody().getMessage()).contains("Project with key 'EXISTING' already exists"); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); - } - - @Test - void create_project_returns_internal_server_error_when_project_key_generation_exception_is_thrown() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - - when(projectsFacade.createProject(any(CreateProjectRequest.class))) - .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED"); - assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); verify(projectRequestValidator).validate(request); + verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); } @Test @@ -140,23 +100,7 @@ void get_project_returns_not_found_when_project_does_not_exist() { assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); - } - - @Test - void get_project_returns_internal_server_error_when_service_throws_exception() { - when(projectsFacade.getProject(anyString())) - .thenThrow(new RuntimeException("Database error")); - - ResponseEntity result = sut.getProject("PROJ01"); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("003"); - assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectsFacade).getProject("UNKNOWN"); } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java index a3d5142..45435a0 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -7,8 +7,11 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -22,9 +25,9 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; class ProjectExceptionHandlerTest { @@ -197,6 +200,71 @@ private static Stream provideValidationCases() { "Bad Request" ) ); - } -} + } + + @Test + void handle_project_creation_exception_returns_conflict() { + ProjectCreationException exception = new ProjectCreationException("error message"); + + ResponseEntity result = sut.handleProjectCreationException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("error message", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_client_app_not_registered_exception_returns_forbidden() { + ClientAppNotRegisteredException exception = new ClientAppNotRegisteredException("client-123"); + + ResponseEntity result = sut.handleClientAppNotRegisteredException(exception); + + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), result.getBody().getError()); + assertEquals("027", result.getBody().getErrorKey()); + assertEquals("ClientApp not registered, manual registration required", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_automation_platform_exception_returns_internal_server_error() { + AutomationPlatformException exception = new AutomationPlatformException("AAP job failed"); + + ResponseEntity result = sut.handleAutomationPlatformException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("AAP job failed", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("Database error"); + + ResponseEntity result = sut.handleGenericException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("An error occurred while processing the request.", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java new file mode 100644 index 0000000..c537efd --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -0,0 +1,180 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectCreationCommandBuilderTest { + + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectExistenceService projectExistenceService; + + private ProjectCreationCommandBuilder sut; + + private AutoCloseable mocks; + + @BeforeEach + void set_up() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectCreationCommandBuilder(generateProjectKeyService, projectExistenceService); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); + } + + @Test + void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + } + + @Test + void build_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); + when(projectExistenceService.isProjectFound("DLSS000001")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS000001", result.getProjectKey()); + verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); + } + + @Test + void build_throws_validation_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_flavor_and_config_item_are_missing() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, null, "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { + ClientAppEntity clientApp = build_client_app(List.of( + build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"), + build_flavor("AMP", "CI-001", new String[] {}, "eu", "owner2"))); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_project_key_generation_exception_when_generation_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")) + .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); + + assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); + } + + private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey(projectKey); + request.setProjectFlavor(flavor); + request.setConfigurationItem(configItem); + request.setProjectName("Test Project"); + request.setProjectDescription("A test project"); + request.setOwner("testowner"); + request.setLocation("eu"); + request.setX2OdsAccount("x2test"); + return request; + } + + private ClientAppEntity build_client_app(List flavors) { + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(CLIENT_ID) + .clientName("Test App") + .build(); + entity.setProjectFlavors(flavors); + return entity; + } + + private ClientAppProjectFlavorEntity build_flavor( + String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { + return ClientAppProjectFlavorEntity.builder() + .name(name) + .configItem(configItem) + .allowedConfigItems(allowedConfigItems) + .projectKeyPattern(name + "%06d") + .location(location) + .projectOwner(projectOwner) + .build(); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index 89ff398..eb15edd 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -1,95 +1,175 @@ package org.opendevstack.apiservice.project.facade.impl; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; -import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) class ProjectsFacadeImplTest { + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + @Mock private ProjectService projectService; - - private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); + + @Mock + private ProjectMapper projectMapper; + + @Mock + private AutomationParametersMapper automationParametersMapper; + + @Mock + private ProjectCreationResponseMapper projectCreationResponseMapper; + + @Mock + private ProjectCreationCommandBuilder projectCreationCommandBuilder; + + @Mock + private ClientAppService clientAppService; + + @Mock + private AutomationPlatformService automationPlatformService; private ProjectsFacadeImpl sut; + private AutoCloseable mocks; @BeforeEach - void setup() { - sut = new ProjectsFacadeImpl(projectService, projectMapper); + void set_up() throws Exception { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl( + projectService, + projectMapper, + automationParametersMapper, + projectCreationResponseMapper, + projectCreationCommandBuilder, + clientAppService, + automationPlatformService); + + Field workflowField = ProjectsFacadeImpl.class.getDeclaredField("createProjectWorkflow"); + workflowField.setAccessible(true); + workflowField.set(sut, "create-project-workflow"); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); } @Test - void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + void create_project_returns_success_response_when_automation_is_successful() { CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey("PROJ01"); - - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.PENDING); - - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(serviceResponse); - - CreateProjectResponse response = sut.createProject(request); - - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Pending"); - verify(projectService).createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class)); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Pending"); + apiResponse.setProjectFlavor("DLSS"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of("project_key", "DLSS01")); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job-1", "ok")); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertEquals("Pending", result.getStatus()); + assertEquals("DLSS", result.getProjectFlavor()); + verify(projectCreationCommandBuilder).build(request, clientApp); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); } @Test - void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + void create_project_throws_project_creation_exception_when_automation_is_not_successful() { CreateProjectRequest request = new CreateProjectRequest(); - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(null); - - CreateProjectResponse response = sut.createProject(request); - - assertThat(response).isNull(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); + when(projectService.saveProject(any(ProjectRequest.class))) + .thenReturn(ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build()); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of()); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.failure("job-1", "error", "workflow failed")); + + assertThrows(ProjectCreationException.class, () -> sut.createProject(request, CLIENT_ID)); } @Test - void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.RUNNING); + void get_project_returns_mapped_response_when_service_returns_project() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse mappedResponse = new CreateProjectResponse(); + mappedResponse.setProjectKey("PROJ01"); + mappedResponse.setStatus("Running"); when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + when(projectMapper.toApiResponse(serviceResponse)).thenReturn(mappedResponse); - CreateProjectResponse response = sut.getProject("PROJ01"); + CreateProjectResponse result = sut.getProject("PROJ01"); - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Running"); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Running", result.getStatus()); } @Test - void getProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + void get_project_returns_null_when_service_returns_null() { when(projectService.getProject("UNKNOWN")).thenReturn(null); + when(projectMapper.toApiResponse(null)).thenReturn(null); - CreateProjectResponse response = sut.getProject("UNKNOWN"); + CreateProjectResponse result = sut.getProject("UNKNOWN"); - assertThat(response).isNull(); + assertNull(result); } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java new file mode 100644 index 0000000..a0737fa --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -0,0 +1,58 @@ +package org.opendevstack.apiservice.project.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; + +class ClientAppServiceTest { + + @Mock + private ClientAppRepository clientAppRepository; + private ClientAppService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ClientAppService(clientAppRepository); + } + + @Test + void findByClientId_returns_entity_when_client_exists() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(clientId) + .clientName("Test App") + .build(); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.of(entity)); + + ClientAppEntity result = sut.findByClientId(clientId); + + assertThat(result).isNotNull(); + assertThat(result.getClientId()).isEqualTo(clientId); + assertThat(result.getClientName()).isEqualTo("Test App"); + verify(clientAppRepository).findDetailedByClientId(clientId); + } + + @Test + void findByClientId_throws_exception_when_client_not_found() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.empty()); + + assertThrows( + ClientAppNotRegisteredException.class, + () -> sut.findByClientId(clientId)); + verify(clientAppRepository).findDetailedByClientId(clientId); + } +} \ No newline at end of file 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 3f46360..596f39c 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 @@ -8,6 +8,9 @@ import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import java.lang.reflect.Field; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -17,8 +20,13 @@ class ProjectRequestValidatorTest { private ProjectRequestValidator sut; @BeforeEach - void setUp() { + void setUp() throws NoSuchFieldException, IllegalAccessException { sut = new ProjectRequestValidator(); + + Field locationsField = ProjectRequestValidator.class.getDeclaredField("locations"); + locationsField.setAccessible(true); + locationsField.set(sut, List.of("MADRID", "BARCELONA", "SANT_CUGAT")); + } @Test @@ -29,8 +37,8 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_null() { request.setConfigurationItem(null); ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.validate(request) + ProjectValidationException.class, + () -> sut.validate(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -44,8 +52,8 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() request.setConfigurationItem(" "); ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.validate(request) + ProjectValidationException.class, + () -> sut.validate(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -53,9 +61,9 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() @ParameterizedTest @CsvSource({ - "STANDARD, null", - "null, JIRA", - "STANDARD, JIRA" + "STANDARD, null", + "null, JIRA", + "STANDARD, JIRA" }) void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { CreateProjectRequest request = new CreateProjectRequest(); @@ -65,4 +73,81 @@ void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, assertDoesNotThrow(() -> sut.validate(request)); } + + @Test + void validate_throws_exception_when_x2account_present_but_owner_missing() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setX2OdsAccount("x2abc123"); + request.setOwner(null); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + + assertEquals(ErrorKey.MANDATORY_OWNER, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "x2abc, owner1", + "x2x, xowner", + "null, owner1", + "x2valid, ownerX" + }) + void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setX2OdsAccount("null".equals(x2) ? null : x2); + request.setOwner(owner); + + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_succeeds_when_location_is_null_or_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation(null); + assertDoesNotThrow(() -> sut.validate(request)); + + request.setLocation(""); + assertDoesNotThrow(() -> sut.validate(request)); + + request.setLocation(" "); + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_throws_exception_when_location_not_in_allowed_list() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation("INVALID_LOCATION_NOT_ALLOWED"); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + + assertEquals(ErrorKey.INVALID_LOCATION, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "MADRID", + "BARCELONA", + "SANT_CUGAT" + }) + void validate_succeeds_when_location_is_allowed(String location) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setLocation(location); + + assertDoesNotThrow(() -> sut.validate(request)); + } } \ No newline at end of file diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java index 2c7686f..6a0805b 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java @@ -3,7 +3,7 @@ /** * Exception thrown when there are issues with automation platform operations. */ -public class AutomationPlatformException extends Exception { +public class AutomationPlatformException extends RuntimeException { private final String errorCode; diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java index 57cecb7..57b042f 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java @@ -22,7 +22,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the execution result * @throws AutomationPlatformException if workflow execution fails */ - AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) throws AutomationPlatformException; + AutomationExecutionResult executeWorkflow(String workflowName, Map parameters); /** * Executes a workflow on the automation platform asynchronously. @@ -50,7 +50,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the workflow job status and result * @throws AutomationPlatformException if status check fails */ - AutomationJobStatus getWorkflowJobStatus(String workflowId) throws AutomationPlatformException; + AutomationJobStatus getWorkflowJobStatus(String workflowId); /** diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java index 31db228..d539c21 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.UUID; @Service public class ClientDaoImpl implements ClientDao { @@ -18,10 +19,21 @@ public ClientDaoImpl(ClientAppRepository repository) { @Override public Optional findByClientId(String clientId) { - return repository.findByClientId(clientId) + if (clientId == null || clientId.isBlank()) { + return Optional.empty(); + } + + final UUID clientUuid; + try { + clientUuid = UUID.fromString(clientId); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + + return repository.findByClientId(clientUuid) .map(entity -> new ClientInfo( entity.getId(), - entity.getClientId(), + entity.getClientId().toString(), entity.getClientName(), entity.isEnabled() )); diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java index 74f0875..98a1421 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -1,11 +1,16 @@ package org.opendevstack.apiservice.persistence.entity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,8 +36,8 @@ public class ClientAppEntity extends AuditableEntity { private UUID id; /** Azure AD application/client identifier. */ - @Column(name = "client_id", nullable = false, unique = true, length = 36) - private String clientId; + @Column(name = "client_id", nullable = false, unique = true) + private UUID clientId; /** Optional display name for the client application. */ @Column(name = "client_name", length = 255) @@ -43,4 +48,9 @@ public class ClientAppEntity extends AuditableEntity { @Builder.Default private boolean enabled = true; + /** Project flavor configurations associated with this client. */ + @OneToMany(mappedBy = "clientApp", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List projectFlavors = new ArrayList<>(); + } \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java index 1af9a8d..b088a85 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -19,7 +19,7 @@ public interface ClientAppRepository extends JpaRepository findByClientId(String clientId); + Optional findByClientId(UUID clientId); /** * Returns all enabled client applications. @@ -32,7 +32,7 @@ public interface ClientAppRepository extends JpaRepository findDetailedByClientId(String clientId); + Optional findDetailedByClientId(UUID clientId); } \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql new file mode 100644 index 0000000..852026f --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +--changeset ods:004-alter-client-apps-client-id-to-uuid +-- Description: Converts client_apps.client_id from VARCHAR(36) to UUID to align DB and Java types. +ALTER TABLE client_apps + ALTER COLUMN client_id TYPE UUID + USING client_id::uuid; + +--rollback ALTER TABLE client_apzzps +--rollback ALTER COLUMN client_id TYPE VARCHAR(36) +--rollback USING client_id::text; + diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java index bf31346..4d433e7 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java @@ -1,10 +1,5 @@ package org.opendevstack.apiservice.persistence.repository; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -14,13 +9,18 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + /** * Integration tests for {@link ClientAppRepository}. */ @@ -55,11 +55,11 @@ static void postgresProperties(DynamicPropertyRegistry registry) { void setUp() { repository.deleteAll(); - enabledClientApp = createClientApp("11111111-1111-1111-1111-111111111111", true); - createClientApp("22222222-2222-2222-2222-222222222222", false); + enabledClientApp = createClientApp(UUID.fromString("11111111-1111-1111-1111-111111111111"), true); + createClientApp(UUID.fromString("22222222-2222-2222-2222-222222222222"), false); } - private ClientAppEntity createClientApp(String clientId, boolean enabled) { + private ClientAppEntity createClientApp(UUID clientId, boolean enabled) { ClientAppEntity clientApp = ClientAppEntity.builder() .clientId(clientId) .clientName(enabled ? "Enabled app" : "Disabled app") @@ -76,7 +76,7 @@ class FindByClientId { @Test @DisplayName("returns the matching client app") void returnsMatchingClientApp() { - Optional found = repository.findByClientId("11111111-1111-1111-1111-111111111111"); + Optional found = repository.findByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111")); assertThat(found).isPresent(); assertThat(found.get().getClientName()).isEqualTo("Enabled app"); @@ -86,7 +86,7 @@ void returnsMatchingClientApp() { @Test @DisplayName("returns empty for unknown client id") void returnsEmptyForUnknownClientId() { - assertThat(repository.findByClientId("99999999-9999-9999-9999-999999999999")).isEmpty(); + assertThat(repository.findByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isEmpty(); } } @@ -101,7 +101,7 @@ void returnsOnlyEnabledClientApps() { List enabledApps = repository.findByEnabledTrue(); assertThat(enabledApps).hasSize(1); - assertThat(enabledApps.get(0).getClientId()).isEqualTo("11111111-1111-1111-1111-111111111111"); + assertThat(enabledApps.get(0).getClientId().toString()).isEqualTo("11111111-1111-1111-1111-111111111111"); } } @@ -112,13 +112,13 @@ class ExistsByClientId { @Test @DisplayName("returns true for existing client id") void returnsTrueForExistingClientId() { - assertThat(repository.existsByClientId("11111111-1111-1111-1111-111111111111")).isTrue(); + assertThat(repository.existsByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111"))).isTrue(); } @Test @DisplayName("returns false for unknown client id") void returnsFalseForUnknownClientId() { - assertThat(repository.existsByClientId("99999999-9999-9999-9999-999999999999")).isFalse(); + assertThat(repository.existsByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isFalse(); } } diff --git a/service-projects/pom.xml b/service-projects/pom.xml index 2a82727..f391ffe 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -69,7 +69,13 @@ org.opendevstack.apiservice external-service-ocp ${project.version} - + + + + org.opendevstack.apiservice + external-service-aap + ${project.version} + org.projectlombok diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java new file mode 100644 index 0000000..fa3a5d0 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectExistenceServiceException extends Exception { + + public ProjectExistenceServiceException(String message) { + super(message); + } + + public ProjectExistenceServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java index 18a6491..72060ad 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -12,6 +12,7 @@ public interface ProjectResponseMapper { @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "id", target = "projectId") ProjectResponse toCreateProjectResponse(ProjectEntity entity); @Named("mapStatus") diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index 186ffaa..9bc0f91 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -4,16 +4,32 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor public class ProjectRequest { + private UUID projectId; + private String projectKey; - - private String projectKeyPattern; - + private String projectName; - + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; + + private Status status; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java index 40ee62a..6db5063 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -5,15 +5,25 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ProjectResponse { + private UUID projectId; + private String projectKey; private Status status; private String projectFlavor; + + private String message; + + private String error; + + private String errorKey; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java index 289b977..b00d7bf 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -1,11 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; public interface GenerateProjectKeyService { String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; - String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException; + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java new file mode 100644 index 0000000..ed13614 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; + +public interface ProjectExistenceService { + + boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index c2abcc6..f2ba3aa 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -5,7 +5,7 @@ public interface ProjectService { - ProjectResponse createProject(ProjectRequest request); + ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java index 2e53781..7b80fd3 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -1,21 +1,15 @@ package org.opendevstack.apiservice.serviceproject.service.impl; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.security.SecureRandom; -import java.util.Comparator; import java.util.Random; -import java.util.Set; @Service @Slf4j @@ -23,37 +17,29 @@ public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService private static final int MAX_RETRIES = 10; - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; + private final ProjectExistenceService projectExistenceService; private final Random random; @Autowired - public GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService) { - this(bitbucketService, jiraService, openshiftService, new SecureRandom()); + public GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService) { + this(projectExistenceService, new SecureRandom()); } - GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService, Random random) { - this.bitbucketService = bitbucketService; - this.jiraService = jiraService; - this.openshiftService = openshiftService; + GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService, Random random) { + this.projectExistenceService = projectExistenceService; this.random = random; } @Override - public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException { + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException { String pattern = resolveProjectKeyPattern(projectKeyPattern); for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { int randomNumber = random.nextInt(1_000_000); String projectKey = String.format(pattern, randomNumber); - if (!isProjectFound(projectKey)) { + if (!projectExistenceService.isProjectFound(projectKey)) { log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); return projectKey; } @@ -71,71 +57,4 @@ private String resolveProjectKeyPattern(String projectKeyPattern) { } return projectKeyPattern; } - - private boolean isProjectFound(String projectKey) throws ProjectKeyGenerationException { - try { - if (existsInAnyBitbucketInstance(projectKey)) { - return true; - } - - if (existsInAnyJiraInstance(projectKey)) { - return true; - } - - return existsInAnyOpenshift(projectKey); - } catch (BitbucketException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Bitbucket", projectKey), e); - } catch (JiraException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Jira", projectKey), e); - } catch (OpenshiftException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Openshift", projectKey), e); - } - } - - private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { - Set instances = bitbucketService.getAvailableInstances(); - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (bitbucketService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { - Set instances = jiraService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return jiraService.projectExists(projectKey); - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (jiraService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { - Set instances = openshiftService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return false; - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (openshiftService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java new file mode 100644 index 0000000..a448376 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -0,0 +1,84 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.Set; + +@Service +public class ProjectExistenceServiceImpl implements ProjectExistenceService { + + private final BitbucketService bitbucketService; + private final JiraService jiraService; + private final OpenshiftService openshiftService; + private final ProjectService projectService; + + @Autowired + public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, ProjectService projectService) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.projectService = projectService; + } + + @Override + public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { + try { + if (existsInCollection(projectKey)) return true; + if (existsInAnyBitbucketInstance(projectKey)) return true; + if (existsInAnyJiraInstance(projectKey)) return true; + return existsInAnyOpenshift(projectKey); + } catch (BitbucketException e) { + throw new ProjectExistenceServiceException("Failed to check project in Bitbucket", e); + } catch (JiraException e) { + throw new ProjectExistenceServiceException("Failed to check project in Jira", e); + } catch (OpenshiftException e) { + throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); + } catch (RuntimeException e) { + throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); + } + } + + private boolean existsInCollection(String projectKey) { + return projectService.getProject(projectKey) != null; + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) return false; + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instance, projectKey)) return true; + } + return false; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 1d8d7b6..ccd3be4 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -1,41 +1,53 @@ package org.opendevstack.apiservice.serviceproject.service.impl; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.Optional; @Service @Slf4j -@AllArgsConstructor public class ProjectServiceImpl implements ProjectService { - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; - - private final GenerateProjectKeyService generateProjectKeyService; + private static final String MANAGER_ROLE = "MANAGER"; + private static final String TEAM_ROLE = "TEAM"; + private static final String STAKEHOLDER_ROLE = "STAKEHOLDER"; + @Value("${ldap.group.pattern}") + private String ldapGroupPattern; + private final ProjectRepository projectRepository; private final ProjectResponseMapper projectResponseMapper; + + public ProjectServiceImpl(ProjectRepository projectRepository, + ProjectResponseMapper projectResponseMapper) { + this.projectRepository = projectRepository; + this.projectResponseMapper = projectResponseMapper; + } @Override - public ProjectResponse createProject(ProjectRequest request) { - return ProjectResponse.builder().build(); + public ProjectResponse saveProject(ProjectRequest request) { + ProjectEntity entity = new ProjectEntity(); + entity.setProjectKey(request.getProjectKey()); + entity.setProjectName(request.getProjectName()); + entity.setProjectFlavor(request.getProjectFlavor()); + entity.setConfigurationItem(request.getConfigurationItem()); + entity.setDescription(request.getProjectFlavor() + " project"); + entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); + entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); + entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); + entity.setStatus(request.getStatus().getDbValue()); + entity.setLocation(request.getLocation()); + ProjectEntity save = projectRepository.save(entity); + return projectResponseMapper.toCreateProjectResponse(save); } @Override @@ -48,5 +60,11 @@ public ProjectResponse getProject(String projectKey) { return null; } + + private String getLdapGroup(String role, String projectKey) { + return ldapGroupPattern + .replace("{{projectKey}}", projectKey) + .replace("{{role}}", role); + } } diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java index ebba523..6e5b02f 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -7,13 +7,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import java.util.Random; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,13 +22,7 @@ class GenerateProjectKeyServiceImplTest { @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private OpenshiftService openshiftService; + private ProjectExistenceService projectExistenceService; @Mock private Random random; @@ -40,17 +31,13 @@ class GenerateProjectKeyServiceImplTest { @BeforeEach void setup() { - tested = new GenerateProjectKeyServiceImpl(bitbucketService, jiraService, openshiftService, random); - when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("dev")); - when(jiraService.getAvailableInstances()).thenReturn(Set.of("default")); - when(openshiftService.getAvailableInstances()).thenReturn(Set.of()); + tested = new GenerateProjectKeyServiceImpl(projectExistenceService, random); } @Test - void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exception { + void generate_project_key_returns_key_when_first_candidate_is_free() throws Exception { when(random.nextInt(1_000_000)).thenReturn(7); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000007")).thenReturn(false); String result = tested.generateProjectKey(null); @@ -58,14 +45,10 @@ void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exceptio } @Test - void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws Exception { + void generate_project_key_retries_until_unique_key_found() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2); - - when(bitbucketService.projectExists("dev", "SS000001")).thenReturn(true); - when(jiraService.projectExists("default", "SS000001")).thenReturn(false); - - when(bitbucketService.projectExists("dev", "SS000002")).thenReturn(false); - when(jiraService.projectExists("default", "SS000002")).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000001")).thenReturn(true); + when(projectExistenceService.isProjectFound("SS000002")).thenReturn(false); String result = tested.generateProjectKey("SS%06d"); @@ -73,12 +56,9 @@ void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws E } @Test - void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() throws Exception { + void generate_project_key_throws_exception_when_no_unique_key_after_max_retries() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); - - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - - when(jiraService.projectExists(anyString(), anyString())).thenReturn(true); + when(projectExistenceService.isProjectFound(anyString())).thenReturn(true); assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) .isInstanceOf(ProjectKeyGenerationException.class) @@ -86,10 +66,9 @@ void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() thro } @Test - void generateProjectKey_whenCustomPatternProvided_thenUseIt() throws Exception { + void generate_project_key_uses_custom_pattern_when_provided() throws Exception { when(random.nextInt(1_000_000)).thenReturn(42); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("AB0042")).thenReturn(false); String result = tested.generateProjectKey("AB%04d"); diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java new file mode 100644 index 0000000..aaabce3 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -0,0 +1,137 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProjectExistenceServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + @Mock + private JiraService jiraService; + @Mock + private OpenshiftService openshiftService; + @Mock + private ProjectService projectService; + + private ProjectExistenceServiceImpl sut; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this).close(); + sut = new ProjectExistenceServiceImpl(bitbucketService, jiraService, openshiftService, projectService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.getProject("KEY1")).thenReturn(new ProjectResponse()); + + boolean result = sut.isProjectFound("KEY1"); + assertTrue(result); + verify(projectService).getProject("KEY1"); + verifyNoInteractions(bitbucketService, jiraService, openshiftService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_bitbucket() throws Exception { + when(projectService.getProject("KEY2")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY2")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY2"); + assertTrue(result); + verify(bitbucketService).projectExists("inst1", "KEY2"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_jira() throws Exception { + when(projectService.getProject("KEY3")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY3")).thenReturn(true); + boolean result = sut.isProjectFound("KEY3"); + assertTrue(result); + verify(jiraService).projectExists("jira1", "KEY3"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_openshift() throws Exception { + when(projectService.getProject("KEY4")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + when(openshiftService.projectExists("ocp1", "KEY4")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY4"); + assertTrue(result); + verify(openshiftService).projectExists("ocp1", "KEY4"); + } + + @Test + void is_project_found_returns_false_when_project_not_found_anywhere() throws Exception { + when(projectService.getProject("KEY5")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY5")).thenReturn(false); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY5")).thenReturn(false); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + + when(openshiftService.projectExists("ocp1", "KEY5")).thenReturn(false); + boolean result = sut.isProjectFound("KEY5"); + assertFalse(result); + } + + @Test + void is_project_found_throws_exception_when_bitbucket_fails() { + when(projectService.getProject("KEY6")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY6"); + }); + } + + @Test + void is_project_found_throws_exception_when_jira_fails() { + when(projectService.getProject("KEY7")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY7"); + }); + } + + @Test + void is_project_found_throws_exception_when_openshift_fails() { + when(projectService.getProject("KEY8")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY8"); + }); + } +} \ No newline at end of file diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java index 827ba41..51adb00 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -4,16 +4,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; -import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import java.util.Optional; import java.util.UUID; @@ -29,18 +24,6 @@ class ProjectServiceImplTest { - @Mock - private OpenshiftService openshiftService; - - @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private GenerateProjectKeyService generateProjectKeyService; - @Mock private ProjectRepository projectRepository; @@ -53,10 +36,6 @@ class ProjectServiceImplTest { void setUp() { MockitoAnnotations.openMocks(this); projectService = new ProjectServiceImpl( - openshiftService, - bitbucketService, - jiraService, - generateProjectKeyService, projectRepository, projectResponseMapper ); @@ -89,10 +68,8 @@ void get_project_returns_response_when_project_exists() { when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(projectEntity)); when(projectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); - ProjectResponse result = projectService.getProject(projectKey); - assertNotNull(result); assertEquals(projectKey, result.getProjectKey()); @@ -107,33 +84,14 @@ void get_project_returns_null_when_project_does_not_exist() { String projectKey = "NON-EXISTING"; when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(projectKey); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); verify(projectResponseMapper, never()).toCreateProjectResponse(any()); } - @Test - void create_project_returns_empty_response() { - - ProjectRequest request = new ProjectRequest(); - request.setProjectKey("NEW-PROJECT"); - request.setProjectKeyPattern("NEW%06d"); - request.setProjectName("New Project"); - request.setProjectDescription("New test project"); - - - ProjectResponse result = projectService.createProject(request); - - - assertNotNull(result); - assertNull(result.getProjectKey()); - } - @Test void get_project_propagates_repository_exception() { @@ -150,10 +108,8 @@ void get_project_propagates_repository_exception() { void get_project_returns_null_when_project_key_is_null() { when(projectRepository.findByProjectKeyIgnoreCase(null)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(null); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(null);