diff --git a/.gitignore b/.gitignore index 46a7edea..b254917f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,8 @@ api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/a api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model api-project/src/main/java/org/opendevstack/apiservice/project/api api-project/src/main/java/org/opendevstack/apiservice/project/model +api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/api +api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/model api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/openapi diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml new file mode 100644 index 00000000..377dcc96 --- /dev/null +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -0,0 +1,126 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v1 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects + description: API for manage EDP projects. +paths: + /projects: + get: + tags: + - Projects + summary: Get all projects filtered by page and size. + description: Returns the current status and details for each project in a list filtered by the given page and size. + operationId: getProjects + parameters: + - name: page + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + description: Page number to retrieve. + - name: size + in: query + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 1000 + description: Size of the page to retrieve. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + ProjectsResponse: + type: object + properties: + projectKey: + type: string + projectName: + type: string + projectFlavor: + type: string + location: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + GetProjectsResponse: + type: object + properties: + projects: + type: array + items: + $ref: '#/components/schemas/ProjectsResponse' + metadata: + type: object + properties: + page: + type: integer + size: + type: integer + totalElements: + type: integer + totalPages: + type: integer + last: + type: boolean + message: + type: string + error: + type: string + errorKey: + type: string + location: + type: string \ No newline at end of file diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml new file mode 100644 index 00000000..e3a18fba --- /dev/null +++ b/api-project-v1/pom.xml @@ -0,0 +1,182 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project-v1 + API Projects V1 + API module for managing EDP projects + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + org.opendevstack.apiservice + core-security + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project-v1 + + generate + + + spring + ${project.basedir}/target/generated-sources/openapi + spring-boot + ${project.basedir}/openapi/api-project-v1.yaml + org.opendevstack.apiservice.projectv1.client.api + org.opendevstack.apiservice.projectv1.client.model + org.opendevstack.apiservice.projectv1.client + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java new file mode 100644 index 00000000..68387325 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.projectv1.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.client.api.ProjectsApi; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController("projectControllerV1") +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private final ProjectsFacade projectsFacade; + + @GetMapping + @Override + public ResponseEntity getProjects(Integer page, Integer size) { + + GetProjectsResponse response = projectsFacade.getProjects(page, size); + response.setLocation(API_BASE_PATH); + + return ResponseEntity + .status(HttpStatus.OK) + .body(response); + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 00000000..181f7c65 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,108 @@ +package org.opendevstack.apiservice.projectv1.controller.advice; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.opendevstack.apiservice.projectv1.exception.PageNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Map; +import java.util.Optional; + +@Component("projectExceptionHandlerV1") +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "page", ErrorKey.INVALID_PAGE, + "size", ErrorKey.INVALID_SIZE + ); + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Request validation error: {}", ex.getMessage()); + + Optional error = ex.getConstraintViolations() + .stream() + .map(this::formatConstraintViolation) + .findFirst(); + + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (error.isPresent()) { + ErrorKey key = error.get(); + response.setErrorKey(key.getKey()); + response.setMessage(key.getMessage()); + } else { + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage()); + } + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + log.warn("Request validation error: {}", ex.getMessage()); + + ErrorKey errorKey = FIELD_ERROR_MAP.getOrDefault(ex.getName(), ErrorKey.BAD_REQUEST_BODY); + + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(PageNotFoundException.class) + public ResponseEntity handlePageNotFoundException( + PageNotFoundException ex) { + log.warn("Get Projects error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey(); + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.NOT_FOUND.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + GetProjectsResponse response = new GetProjectsResponse(); + 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); + } + + private ErrorKey formatConstraintViolation(ConstraintViolation violation) { + Path path = violation.getPropertyPath(); + + String lastNodeName = null; + for (Path.Node node : path) { + lastNodeName = node.getName(); + } + + return FIELD_ERROR_MAP.getOrDefault(lastNodeName, ErrorKey.BAD_REQUEST_BODY); + } +} \ No newline at end of file diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java new file mode 100644 index 00000000..4993ad17 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.projectv1.exception; + +public enum ErrorKey { + + INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR), + BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), + INVALID_PAGE("031", ErrorMessage.INVALID_PAGE), + INVALID_SIZE("032", ErrorMessage.INVALID_SIZE), + PAGE_NOT_FOUND("033", ErrorMessage.PAGE_NOT_FOUND); + + private String key; + private String message; + + ErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return this.key; + } + + public String getMessage() { + return this.message; + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java new file mode 100644 index 00000000..d2ac0351 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.projectv1.exception; + +public class ErrorMessage { + + public static final String BAD_REQUEST = "Bad Request"; + public static final String INTERNAL_ERROR = "Internal error"; + + public static final String INVALID_PAGE = "page must be an integer greater or equal to 0"; + public static final String INVALID_SIZE = "size must be an integer between 1 and 1000"; + public static final String PAGE_NOT_FOUND = "page not found"; + + private ErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/PageNotFoundException.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/PageNotFoundException.java new file mode 100644 index 00000000..d07b5092 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/PageNotFoundException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.projectv1.exception; + +import lombok.Getter; + +@Getter +public class PageNotFoundException extends RuntimeException { + + private final ErrorKey errorKey; + + public PageNotFoundException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } + + public PageNotFoundException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java new file mode 100644 index 00000000..5362c752 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.projectv1.facade; + +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; + +public interface ProjectsFacade { + + GetProjectsResponse getProjects(Integer page, Integer size); +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 00000000..5ce4e481 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.projectv1.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; + +@Component("apiProjectFacadeImplV1") +@AllArgsConstructor +@Slf4j +public class ProjectsFacadeImpl implements ProjectsFacade { + + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + + private final ProjectService projectService; + + private final ProjectMapper projectMapper; + + @Override + public GetProjectsResponse getProjects(Integer page, Integer size) { + page = page == null ? DEFAULT_PAGE : page; + size = size == null ? DEFAULT_SIZE : size; + return projectMapper.toApiResponse(projectService.getProjects(page, size)); + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java new file mode 100644 index 00000000..f19d0f9d --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java @@ -0,0 +1,70 @@ +package org.opendevstack.apiservice.projectv1.mapper; + +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.opendevstack.apiservice.projectv1.exception.PageNotFoundException; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component("projectMapperV1") +public class ProjectMapper { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + public GetProjectsResponse toApiResponse(Page projectPage) { + int effectivePage = projectPage.getNumber(); + int effectiveSize = projectPage.getSize(); + int totalElements = (int) projectPage.getTotalElements(); + int totalPages = projectPage.getTotalPages(); + + if (!isPageZeroCase(effectivePage, totalPages) && (effectivePage + 1) > totalPages) { + throw new PageNotFoundException( + ErrorKey.PAGE_NOT_FOUND, + "within the " + totalPages + " total pages." + ); + } + + boolean isLast = isPageZeroCase(effectivePage, totalPages) || (effectivePage + 1) == totalPages; + + List pageContent = projectPage.getContent().stream() + .map(this::toProjectsResponse) + .toList(); + + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(effectivePage) + .size(effectiveSize) + .totalElements(totalElements) + .totalPages(totalPages) + .last(isLast); + + return new GetProjectsResponse() + .projects(pageContent) + .metadata(metadata); + } + + private ProjectsResponse toProjectsResponse(ProjectSummary source) { + return new ProjectsResponse() + .projectKey(source.getProjectKey()) + .projectName(source.getProjectName()) + .projectFlavor(source.getProjectFlavor()) + .location(source.getLocation()) + .status(source.getStatus() != null ? source.getStatus().name() : null) + .createdAt(formatDateTime(source.getCreatedAt())) + .updatedAt(formatDateTime(source.getUpdatedAt())); + } + + private String formatDateTime(OffsetDateTime dateTime) { + return dateTime != null ? dateTime.format(FORMATTER) : null; + } + + private boolean isPageZeroCase(int effectivePage, int totalPages) { + return effectivePage == 0 && totalPages == 0; + } +} \ No newline at end of file diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java new file mode 100644 index 00000000..ecd3c2e1 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java @@ -0,0 +1,109 @@ +package org.opendevstack.apiservice.projectv1.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + private ProjectController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectController(projectsFacade); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void get_projects_returns_ok_with_projects_list() { + ProjectsResponse project = new ProjectsResponse() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status("RUNNING"); + + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(0) + .size(20) + .totalElements(1) + .totalPages(1) + .last(true); + + GetProjectsResponse facadeResponse = new GetProjectsResponse() + .projects(List.of(project)) + .metadata(metadata); + + when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse); + + ResponseEntity result = sut.getProjects(0, 20); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjects()).hasSize(1); + assertThat(result.getBody().getProjects().get(0).getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + assertThat(result.getBody().getMetadata()).isNotNull(); + assertThat(result.getBody().getMetadata().getTotalElements()).isEqualTo(1); + verify(projectsFacade).getProjects(0, 20); + } + + @Test + void get_projects_returns_ok_with_empty_list_when_no_projects_exist() { + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(0) + .size(20) + .totalElements(0) + .totalPages(0) + .last(true); + + GetProjectsResponse facadeResponse = new GetProjectsResponse() + .projects(List.of()) + .metadata(metadata); + + when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse); + + ResponseEntity result = sut.getProjects(0, 20); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjects()).isEmpty(); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + verify(projectsFacade).getProjects(0, 20); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 00000000..a565b99a --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,168 @@ +package org.opendevstack.apiservice.projectv1.controller.advice; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.opendevstack.apiservice.projectv1.exception.PageNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ProjectExceptionHandlerTest { + + private ProjectExceptionHandler sut; + + @BeforeEach + void setUp() { + sut = new ProjectExceptionHandler(); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_invalid_page() { + ConstraintViolationException ex = createConstraintViolationException("page"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_PAGE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_invalid_size() { + ConstraintViolationException ex = createConstraintViolationException("size"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_SIZE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_unknown_field() { + ConstraintViolationException ex = createConstraintViolationException("unknownField"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_type_mismatching_returns_bad_request_for_invalid_page() { + MethodArgumentTypeMismatchException ex = createMethodArgumentTypeMismatchException("page"); + + ResponseEntity result = sut.handleMethodArgumentTypeMismatchException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_PAGE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_type_mismatching_returns_bad_request_for_invalid_size() { + MethodArgumentTypeMismatchException ex = createMethodArgumentTypeMismatchException("size"); + + ResponseEntity result = sut.handleMethodArgumentTypeMismatchException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_SIZE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_type_mismatching_returns_bad_request_for_unknown_field() { + MethodArgumentTypeMismatchException ex = createMethodArgumentTypeMismatchException("unknownField"); + + ResponseEntity result = sut.handleMethodArgumentTypeMismatchException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_page_not_found_exception_returns_not_found() { + PageNotFoundException ex = new PageNotFoundException(ErrorKey.PAGE_NOT_FOUND); + + ResponseEntity result = sut.handlePageNotFoundException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.NOT_FOUND.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.PAGE_NOT_FOUND.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.PAGE_NOT_FOUND.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + Exception ex = new RuntimeException("Unexpected database failure"); + + ResponseEntity result = sut.handleGenericException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(ErrorKey.INTERNAL_ERROR.getMessage()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INTERNAL_ERROR.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + // ------- helpers ------- + + private ConstraintViolationException createConstraintViolationException(String fieldName) { + Path.Node node = mock(Path.Node.class); + when(node.getName()).thenReturn(fieldName); + + Path path = mock(Path.class); + when(path.iterator()).thenReturn(List.of(node).iterator()); + + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getPropertyPath()).thenReturn(path); + + return new ConstraintViolationException(Set.of(violation)); + } + + private MethodArgumentTypeMismatchException createMethodArgumentTypeMismatchException(String fieldName) { + return new MethodArgumentTypeMismatchException( + "invalid-value", + Integer.class, + fieldName, + null, + null + ); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 00000000..341d7e37 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,143 @@ +package org.opendevstack.apiservice.projectv1.facade.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.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.OffsetDateTime; +import java.util.List; + +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; + +class ProjectsFacadeImplTest { + + @Mock + private ProjectService projectService; + + @Mock + private ProjectMapper projectMapper; + + private ProjectsFacadeImpl sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl(projectService, projectMapper); + } + + @Test + void get_projects_returns_response_from_mapper() { + List summaries = List.of( + ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .build() + ); + Page summaryPage = new PageImpl<>(summaries, PageRequest.of(0, 20), 1); + + GetProjectsResponse expectedResponse = new GetProjectsResponse() + .projects(List.of(new ProjectsResponse().projectKey("PROJ01"))) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(1).totalPages(1).last(true)); + + when(projectService.getProjects(0, 20)).thenReturn(summaryPage); + when(projectMapper.toApiResponse(summaryPage)).thenReturn(expectedResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).hasSize(1); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("PROJ01"); + verify(projectService).getProjects(0, 20); + verify(projectMapper).toApiResponse(summaryPage); + } + + @Test + void get_projects_returns_empty_list_when_no_projects() { + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + + GetProjectsResponse emptyResponse = new GetProjectsResponse() + .projects(List.of()) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(0).totalPages(0).last(true)); + + when(projectService.getProjects(0, 20)).thenReturn(emptyPage); + when(projectMapper.toApiResponse(emptyPage)).thenReturn(emptyResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + verify(projectService).getProjects(0, 20); + verify(projectMapper).toApiResponse(emptyPage); + } + + @Test + void get_projects_propagates_service_exception() { + when(projectService.getProjects(0, 20)).thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, + () -> sut.getProjects(0, 20)); + + verify(projectService).getProjects(0, 20); + } + + @Test + void get_projects_returns_multiple_projects_with_all_fields() { + OffsetDateTime now = OffsetDateTime.now(); + + List summaries = List.of( + ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .createdAt(now) + .updatedAt(now) + .build(), + ProjectSummary.builder() + .projectKey("PROJ02") + .projectName("Project Two") + .projectFlavor("DEFAULT") + .location("us") + .status(Status.PENDING) + .createdAt(now) + .updatedAt(now) + .build() + ); + Page summaryPage = new PageImpl<>(summaries, PageRequest.of(0, 20), 2); + + GetProjectsResponse expectedResponse = new GetProjectsResponse() + .projects(List.of( + new ProjectsResponse().projectKey("PROJ01").status("RUNNING"), + new ProjectsResponse().projectKey("PROJ02").status("PENDING") + )) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(2).totalPages(1).last(true)); + + when(projectService.getProjects(0, 20)).thenReturn(summaryPage); + when(projectMapper.toApiResponse(summaryPage)).thenReturn(expectedResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result.getProjects()).hasSize(2); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(2); + assertThat(result.getMetadata().getLast()).isTrue(); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java new file mode 100644 index 00000000..b09bd1fd --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java @@ -0,0 +1,159 @@ +package org.opendevstack.apiservice.projectv1.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.exception.PageNotFoundException; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProjectMapperTest { + + private ProjectMapper sut; + + @BeforeEach + void setUp() { + sut = new ProjectMapper(); + } + + @Test + void to_api_response_maps_projects_correctly() { + OffsetDateTime createdAt = OffsetDateTime.of(2024, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC); + OffsetDateTime updatedAt = OffsetDateTime.of(2024, 6, 1, 12, 30, 0, 0, ZoneOffset.UTC); + + ProjectSummary summary = ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .createdAt(createdAt) + .updatedAt(updatedAt) + .build(); + + Page page = new PageImpl<>(List.of(summary), PageRequest.of(0, 20), 1); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).hasSize(1); + + ProjectsResponse project = result.getProjects().get(0); + assertThat(project.getProjectKey()).isEqualTo("PROJ01"); + assertThat(project.getProjectName()).isEqualTo("Project One"); + assertThat(project.getProjectFlavor()).isEqualTo("AMP"); + assertThat(project.getLocation()).isEqualTo("eu"); + assertThat(project.getStatus()).isEqualTo("RUNNING"); + assertThat(project.getCreatedAt()).isNotNull(); + assertThat(project.getUpdatedAt()).isNotNull(); + } + + @Test + void to_api_response_returns_empty_list_when_projects_page_is_empty() { + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_calculates_metadata_correctly() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build() + ); + + Page page = new PageImpl<>(summaries, PageRequest.of(0, 20), 3); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result.getMetadata().getPage()).isEqualTo(0); + assertThat(result.getMetadata().getSize()).isEqualTo(20); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(3); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(1); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_paginates_correctly_first_page() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build() + ); + + Page page = new PageImpl<>(summaries, PageRequest.of(0, 2), 5); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result.getProjects()).hasSize(2); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P1"); + assertThat(result.getProjects().get(1).getProjectKey()).isEqualTo("P2"); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(5); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(3); + assertThat(result.getMetadata().getLast()).isFalse(); + } + + @Test + void to_api_response_paginates_correctly_last_page() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P5").build() + ); + + Page page = new PageImpl<>(summaries, PageRequest.of(2, 2), 5); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result.getProjects()).hasSize(1); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P5"); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_throws_exception_when_page_is_greater_than_total_pages() { + // Page 5 requested but only 3 pages exist (5 elements, size 2) + Page page = new PageImpl<>(List.of(), PageRequest.of(5, 2), 5); + + assertThrows(PageNotFoundException.class, + () -> sut.toApiResponse(page)); + } + + @Test + void to_api_response_maps_null_status_to_null_string() { + ProjectSummary summary = ProjectSummary.builder() + .projectKey("P1") + .status(null) + .build(); + + Page page = new PageImpl<>(List.of(summary), PageRequest.of(0, 20), 1); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result.getProjects().get(0).getStatus()).isNull(); + } + + @Test + void to_api_response_maps_null_datetime_fields_to_null() { + ProjectSummary summary = ProjectSummary.builder() + .projectKey("P1") + .createdAt(null) + .updatedAt(null) + .build(); + + Page page = new PageImpl<>(List.of(summary), PageRequest.of(0, 20), 1); + GetProjectsResponse result = sut.toApiResponse(page); + + assertThat(result.getProjects().get(0).getCreatedAt()).isNull(); + assertThat(result.getProjects().get(0).getUpdatedAt()).isNull(); + } +} diff --git a/core/pom.xml b/core/pom.xml index a0ba5310..3c4cd141 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -134,6 +134,12 @@ ${project.version} + + org.opendevstack.apiservice + api-project-v1 + ${project.version} + + org.opendevstack.apiservice api-project-component-v0 diff --git a/pom.xml b/pom.xml index f14bc6df..881bcaf7 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ api-project-users api-project-platform api-project + api-project-v1 api-project-component-v0 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 199a43ba..fd268278 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 @@ -9,7 +9,9 @@ import org.mapstruct.Named; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; +import org.springframework.data.domain.Page; @Mapper(componentModel = "spring") public interface ProjectResponseMapper { @@ -22,6 +24,10 @@ public interface ProjectResponseMapper { @IterableMapping(qualifiedByName = "mapEntityToResponse") List toCreateProjectResponse(List entities); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Named("mapEntityToSummary") + ProjectSummary toProjectSummary(ProjectEntity entity); + @Named("mapStatus") default Status mapStatus(String value) { if (value == null) { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java new file mode 100644 index 00000000..3785347d --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +/** + * Read-only projection used for list operations (e.g. GET /projects). + * Carries the full set of fields needed by the API layer, including + * audit timestamps and display fields not present in {@link ProjectResponse}. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProjectSummary { + + private String projectKey; + + private String projectName; + + private String projectFlavor; + + private String location; + + private Status status; + + private OffsetDateTime createdAt; + + private OffsetDateTime updatedAt; +} + 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 896e7c6d..6b833ea6 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 @@ -2,6 +2,8 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.springframework.data.domain.Page; import java.util.List; @@ -12,7 +14,8 @@ public interface ProjectService { ProjectResponse getProject(String projectKey); void updateProjectStatus(String projectKey, String status); - + List findProjectsByName(String projectName); -} + Page getProjects(Integer page, Integer size); +} \ No newline at end of file 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 fe9b34e3..767af8cc 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 @@ -6,8 +6,13 @@ 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.ProjectSummary; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.util.List; @@ -79,6 +84,18 @@ public List findProjectsByName(String projectName) { return projectResponseMapper.toCreateProjectResponse(projects); } + @Override + public Page getProjects(Integer page, Integer size) { + Pageable pageable = PageRequest.of( + page, + size, + Sort.by("createdAt").ascending() + ); + Page pageResult = projectRepository.findAll(pageable); + + return pageResult.map(projectResponseMapper::toProjectSummary); + } + private String getLdapGroup(String role, String projectKey) { return ldapGroupPattern .replace("{{projectKey}}", projectKey) 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 ca30c712..69ea7964 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 @@ -9,7 +9,12 @@ import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; @@ -232,4 +237,73 @@ void update_project_status_propagates_repository_exception() { verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); } + + @Test + void get_projects_returns_summaries_when_projects_exist() { + ProjectEntity entity1 = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("PROJ01") + .projectName("Project One") + .status("Running") + .build(); + + ProjectEntity entity2 = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("PROJ02") + .projectName("Project Two") + .status("Pending") + .build(); + + ProjectSummary summary1 = ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .status(Status.RUNNING) + .build(); + + ProjectSummary summary2 = ProjectSummary.builder() + .projectKey("PROJ02") + .projectName("Project Two") + .status(Status.PENDING) + .build(); + + Page entityPage = new PageImpl<>(List.of(entity1, entity2), PageRequest.of(0, 20), 2); + + when(projectRepository.findAll(any(Pageable.class))).thenReturn(entityPage); + when(projectResponseMapper.toProjectSummary(entity1)).thenReturn(summary1); + when(projectResponseMapper.toProjectSummary(entity2)).thenReturn(summary2); + + Page result = projectService.getProjects(0, 20); + + assertNotNull(result); + assertEquals(2, result.getContent().size()); + assertEquals("PROJ01", result.getContent().get(0).getProjectKey()); + assertEquals(Status.RUNNING, result.getContent().get(0).getStatus()); + assertEquals("PROJ02", result.getContent().get(1).getProjectKey()); + assertEquals(Status.PENDING, result.getContent().get(1).getStatus()); + verify(projectRepository).findAll(any(Pageable.class)); + verify(projectResponseMapper).toProjectSummary(entity1); + verify(projectResponseMapper).toProjectSummary(entity2); + } + + @Test + void get_projects_returns_empty_list_when_no_projects_exist() { + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + + when(projectRepository.findAll(any(Pageable.class))).thenReturn(emptyPage); + + Page result = projectService.getProjects(0, 20); + + assertNotNull(result); + assertEquals(0, result.getContent().size()); + verify(projectRepository).findAll(any(Pageable.class)); + } + + @Test + void get_projects_propagates_repository_exception() { + when(projectRepository.findAll(any(Pageable.class))).thenThrow(new RuntimeException("DB connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProjects(0, 20)); + + verify(projectRepository).findAll(any(Pageable.class)); + } }