From 483282705f2608b76b08c463533b75bac2d2ec16 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 10:55:41 +0200 Subject: [PATCH 01/11] Add API Projects V1 initial version --- .gitignore | 2 + api-project-v1/openapi/api-project-v1.yaml | 102 ++++++++++ api-project-v1/pom.xml | 182 ++++++++++++++++++ .../project/controller/ProjectController.java | 37 ++++ .../project/facade/ProjectsFacade.java | 9 + .../facade/impl/ProjectsFacadeImpl.java | 14 ++ core/pom.xml | 6 + pom.xml | 1 + 8 files changed, 353 insertions(+) create mode 100644 api-project-v1/openapi/api-project-v1.yaml create mode 100644 api-project-v1/pom.xml create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java diff --git a/.gitignore b/.gitignore index 46a7edea..95a8123f 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/project/api +api-project-v1/src/main/java/org/opendevstack/apiservice/project/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..ea3346ad --- /dev/null +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -0,0 +1,102 @@ +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: 20 + maximum: 1000 + description: Size of the page to retrieve. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponseList' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponseList' + "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/GetProjectsResponseList' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + GetProjectsResponse: + type: object + properties: + projectKey: + type: string + projectName: + type: string + projectFlavor: + type: string + location: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + GetProjectsResponseList: + type: array + items: + $ref: '#/components/schemas/GetProjectsResponse' \ 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..0b28752d --- /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.project.client.api + org.opendevstack.apiservice.project.client.model + org.opendevstack.apiservice.project.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/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java new file mode 100644 index 00000000..fe13a34d --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.client.api.ProjectsApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +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; + +import java.util.List; + +@RestController +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + // private final ProjectRequestValidator projectRequestValidator; + + @GetMapping + @Override + public ResponseEntity> getProjects(@Valid @RequestParam Integer page, + @Valid @RequestParam Integer size) { + return null; + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java new file mode 100644 index 00000000..157582f0 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -0,0 +1,9 @@ +package org.opendevstack.apiservice.project.facade; + +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import java.util.List; + +public interface ProjectsFacade { + + List getProjects(Integer page, Integer size); +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 00000000..0a7a94d5 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; + +import java.util.List; + +public class ProjectsFacadeImpl implements ProjectsFacade { + + @Override + public List getProjects(Integer page, Integer size) { + return List.of(); + } +} 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 From 1874dc731ea9ea2df5b640de01ea025b05c093b5 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 14:44:47 +0200 Subject: [PATCH 02/11] Implement GetProjects API endpoint and add error handling --- api-project-v1/openapi/api-project-v1.yaml | 40 +++++++++--- .../project/controller/ProjectController.java | 15 +++-- .../advice/ProjectExceptionHandler.java | 64 +++++++++++++++++++ .../project/exception/ErrorKey.java | 25 ++++++++ .../project/exception/ErrorMessage.java | 14 ++++ .../project/facade/ProjectsFacade.java | 3 +- .../facade/impl/ProjectsFacadeImpl.java | 18 ++++-- .../project/mapper/ProjectMapper.java | 62 ++++++++++++++++++ .../mapper/ProjectResponseMapper.java | 8 +++ .../serviceproject/model/ProjectSummary.java | 35 ++++++++++ .../service/ProjectService.java | 6 +- .../service/impl/ProjectServiceImpl.java | 7 ++ 12 files changed, 275 insertions(+), 22 deletions(-) create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml index ea3346ad..9a472e44 100644 --- a/api-project-v1/openapi/api-project-v1.yaml +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -46,13 +46,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' '404': description: Project not found. content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' "401": description: Invalid client token on the request. content: @@ -70,7 +70,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' components: schemas: RestErrorMessage: @@ -79,7 +79,7 @@ components: type: string required: - message - GetProjectsResponse: + ProjectsResponse: type: object properties: projectKey: @@ -96,7 +96,31 @@ components: type: string updatedAt: type: string - GetProjectsResponseList: - type: array - items: - $ref: '#/components/schemas/GetProjectsResponse' \ No newline at end of file + 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/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index fe13a34d..6423eac9 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -6,6 +6,7 @@ import org.opendevstack.apiservice.project.client.api.ProjectsApi; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.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; @@ -22,16 +23,18 @@ public class ProjectController implements ProjectsApi { public static final String API_BASE_PATH = "/api/pub/v1/projects"; - private static final String HTTP_HEADER_LOCATION = "Location"; - private final ProjectsFacade projectsFacade; - // private final ProjectRequestValidator projectRequestValidator; - @GetMapping @Override - public ResponseEntity> getProjects(@Valid @RequestParam Integer page, + public ResponseEntity getProjects(@Valid @RequestParam Integer page, @Valid @RequestParam Integer size) { - return null; + + 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/project/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 00000000..5a038490 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,64 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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 java.util.Map; + +@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(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (fieldError != null) { + String field = fieldError.getField(); + + ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); + + 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(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); + } +} \ No newline at end of file diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java new file mode 100644 index 00000000..a3a78e73 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.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); + + 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/project/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java new file mode 100644 index 00000000..5ce3fe0e --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.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 greater or equal to 20"; + + private ErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index 157582f0..b66bcaeb 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,9 +1,8 @@ package org.opendevstack.apiservice.project.facade; import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import java.util.List; public interface ProjectsFacade { - List getProjects(Integer page, Integer size); + GetProjectsResponse getProjects(Integer page, Integer size); } diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 0a7a94d5..43ae84e0 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,14 +1,24 @@ package org.opendevstack.apiservice.project.facade.impl; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; -import java.util.List; - +@Component("apiProjectFacadeImpl") +@AllArgsConstructor +@Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { + private final ProjectService projectService; + + private final ProjectMapper projectMapper; + @Override - public List getProjects(Integer page, Integer size) { - return List.of(); + public GetProjectsResponse getProjects(Integer page, Integer size) { + return projectMapper.toApiResponse(projectService.getProjects(), page, size); } } diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java new file mode 100644 index 00000000..b78f66ca --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +public class ProjectMapper { + + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + public GetProjectsResponse toApiResponse(List projects, Integer page, Integer size) { + int effectivePage = page != null ? page : DEFAULT_PAGE; + int effectiveSize = size != null && size > 0 ? size : DEFAULT_SIZE; + + int totalElements = projects != null ? projects.size() : 0; + int totalPages = (int) Math.ceil((double) totalElements / effectiveSize); + boolean isLast = (effectivePage + 1) >= totalPages; + + List pageContent = projects == null + ? List.of() + : projects.stream() + .skip((long) effectivePage * effectiveSize) + .limit(effectiveSize) + .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; + } +} 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..7a8c1b47 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,6 +9,7 @@ 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; @Mapper(componentModel = "spring") @@ -22,6 +23,13 @@ public interface ProjectResponseMapper { @IterableMapping(qualifiedByName = "mapEntityToResponse") List toCreateProjectResponse(List entities); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Named("mapEntityToSummary") + ProjectSummary toProjectSummary(ProjectEntity entity); + + @IterableMapping(qualifiedByName = "mapEntityToSummary") + List toProjectSummary(List entities); + @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..fdc91b08 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,7 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import java.util.List; @@ -12,7 +13,8 @@ public interface ProjectService { ProjectResponse getProject(String projectKey); void updateProjectStatus(String projectKey, String status); - + List findProjectsByName(String projectName); -} + List getProjects(); +} \ 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..427a9676 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,6 +6,7 @@ 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.stereotype.Service; @@ -79,6 +80,12 @@ public List findProjectsByName(String projectName) { return projectResponseMapper.toCreateProjectResponse(projects); } + @Override + public List getProjects() { + List projects = projectRepository.findAll(); + return projectResponseMapper.toProjectSummary(projects); + } + private String getLdapGroup(String role, String projectKey) { return ldapGroupPattern .replace("{{projectKey}}", projectKey) From 169f317d1d348906792f5075cb261d6660244bfb Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 16:03:17 +0200 Subject: [PATCH 03/11] Add unit tests for ProjectController, ProjectExceptionHandler, ProjectMapper, and ProjectsFacade --- .../controller/ProjectControllerTest.java | 109 +++++++++++ .../advice/ProjectExceptionHandlerTest.java | 92 ++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 138 ++++++++++++++ .../project/mapper/ProjectMapperTest.java | 173 ++++++++++++++++++ .../service/impl/ProjectServiceImplTest.java | 66 +++++++ 5 files changed, 578 insertions(+) create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java new file mode 100644 index 00000000..50c21f70 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -0,0 +1,109 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.project.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/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 00000000..e47542d4 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,92 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ErrorMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +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_method_argument_not_valid_returns_bad_request_for_invalid_page() { + MethodArgumentNotValidException ex = createValidationException("page", "must be >= 0", 0); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(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(ErrorMessage.INVALID_PAGE); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_not_valid_returns_bad_request_for_invalid_size() { + MethodArgumentNotValidException ex = createValidationException("size", "must be >= 20", 0); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(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(ErrorMessage.INVALID_SIZE); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_not_valid_returns_bad_request_for_unknown_field() { + MethodArgumentNotValidException ex = createValidationException("unknownField", "invalid value", "bad"); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorMessage.BAD_REQUEST); + 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 MethodArgumentNotValidException createValidationException(String field, String defaultMessage, Object rejectedValue) { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); + bindingResult.addError(new FieldError("target", field, rejectedValue, false, null, null, defaultMessage)); + MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); + when(ex.getBindingResult()).thenReturn(bindingResult); + return ex; + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 00000000..fe7dd9b2 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,138 @@ +package org.opendevstack.apiservice.project.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.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +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() + ); + + 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()).thenReturn(summaries); + when(projectMapper.toApiResponse(summaries, 0, 20)).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(); + verify(projectMapper).toApiResponse(summaries, 0, 20); + } + + @Test + void get_projects_returns_empty_list_when_no_projects() { + List emptySummaries = List.of(); + + GetProjectsResponse emptyResponse = new GetProjectsResponse() + .projects(List.of()) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(0).totalPages(0).last(true)); + + when(projectService.getProjects()).thenReturn(emptySummaries); + when(projectMapper.toApiResponse(emptySummaries, 0, 20)).thenReturn(emptyResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + verify(projectService).getProjects(); + verify(projectMapper).toApiResponse(emptySummaries, 0, 20); + } + + @Test + void get_projects_propagates_service_exception() { + when(projectService.getProjects()).thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, + () -> sut.getProjects(0, 20)); + + verify(projectService).getProjects(); + } + + @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() + ); + + 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()).thenReturn(summaries); + when(projectMapper.toApiResponse(summaries, 0, 20)).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/project/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java new file mode 100644 index 00000000..c47eb8cb --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -0,0 +1,173 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +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(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + 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_are_null() { + GetProjectsResponse result = sut.toApiResponse(null, 0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); + } + + @Test + void to_api_response_returns_empty_list_when_projects_list_is_empty() { + GetProjectsResponse result = sut.toApiResponse(List.of(), 0, 20); + + 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() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 0, 20); + + 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(), + ProjectSummary.builder().projectKey("P3").build(), + ProjectSummary.builder().projectKey("P4").build(), + ProjectSummary.builder().projectKey("P5").build() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 0, 2); + + 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("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build(), + ProjectSummary.builder().projectKey("P4").build(), + ProjectSummary.builder().projectKey("P5").build() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 2, 2); + + assertThat(result.getProjects()).hasSize(1); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P5"); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_uses_default_page_when_null() { + ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), null, 20); + + assertThat(result.getMetadata().getPage()).isEqualTo(0); + } + + @Test + void to_api_response_uses_default_size_when_null() { + ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, null); + + assertThat(result.getMetadata().getSize()).isEqualTo(20); + } + + @Test + void to_api_response_maps_null_status_to_null_string() { + ProjectSummary summary = ProjectSummary.builder() + .projectKey("P1") + .status(null) + .build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + 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(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + assertThat(result.getProjects().get(0).getCreatedAt()).isNull(); + assertThat(result.getProjects().get(0).getUpdatedAt()).isNull(); + } +} 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..c8c9fbfb 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,6 +9,7 @@ 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 java.util.List; @@ -232,4 +233,69 @@ 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(); + + when(projectRepository.findAll()).thenReturn(List.of(entity1, entity2)); + when(projectResponseMapper.toProjectSummary(List.of(entity1, entity2))).thenReturn(List.of(summary1, summary2)); + + List result = projectService.getProjects(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("PROJ01", result.get(0).getProjectKey()); + assertEquals(Status.RUNNING, result.get(0).getStatus()); + assertEquals("PROJ02", result.get(1).getProjectKey()); + assertEquals(Status.PENDING, result.get(1).getStatus()); + verify(projectRepository).findAll(); + verify(projectResponseMapper).toProjectSummary(List.of(entity1, entity2)); + } + + @Test + void get_projects_returns_empty_list_when_no_projects_exist() { + when(projectRepository.findAll()).thenReturn(List.of()); + when(projectResponseMapper.toProjectSummary(List.of())).thenReturn(List.of()); + + List result = projectService.getProjects(); + + assertNotNull(result); + assertEquals(0, result.size()); + verify(projectRepository).findAll(); + verify(projectResponseMapper).toProjectSummary(List.of()); + } + + @Test + void get_projects_propagates_repository_exception() { + when(projectRepository.findAll()).thenThrow(new RuntimeException("DB connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProjects()); + + verify(projectRepository).findAll(); + } } From 9a4134e5e5e7b51292e292de6a589bf6a92dc853 Mon Sep 17 00:00:00 2001 From: "zxBCN Martin_Castro,Jose (IT EDP) EXTERNAL" Date: Wed, 8 Jul 2026 10:45:42 +0200 Subject: [PATCH 04/11] Refactor project package structure to version 1, updating imports and component names --- api-project-v1/pom.xml | 6 +++--- .../apiservice/project/facade/ProjectsFacade.java | 8 -------- .../controller/ProjectController.java | 12 +++++------- .../controller/advice/ProjectExceptionHandler.java | 10 ++++++---- .../{project => projectv1}/exception/ErrorKey.java | 2 +- .../exception/ErrorMessage.java | 2 +- .../apiservice/projectv1/facade/ProjectsFacade.java | 8 ++++++++ .../facade/impl/ProjectsFacadeImpl.java | 10 +++++----- .../{project => projectv1}/mapper/ProjectMapper.java | 10 +++++----- .../controller/ProjectControllerTest.java | 10 +++++----- .../advice/ProjectExceptionHandlerTest.java | 10 +++++----- .../facade/impl/ProjectsFacadeImplTest.java | 10 +++++----- .../mapper/ProjectMapperTest.java | 6 +++--- 13 files changed, 52 insertions(+), 52 deletions(-) delete mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/controller/ProjectController.java (78%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/controller/advice/ProjectExceptionHandler.java (86%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/exception/ErrorKey.java (90%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/exception/ErrorMessage.java (88%) create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/facade/impl/ProjectsFacadeImpl.java (64%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/mapper/ProjectMapper.java (87%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/controller/ProjectControllerTest.java (91%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/controller/advice/ProjectExceptionHandlerTest.java (92%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/facade/impl/ProjectsFacadeImplTest.java (93%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/mapper/ProjectMapperTest.java (96%) diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml index 0b28752d..e3a18fba 100644 --- a/api-project-v1/pom.xml +++ b/api-project-v1/pom.xml @@ -149,9 +149,9 @@ ${project.basedir}/target/generated-sources/openapi spring-boot ${project.basedir}/openapi/api-project-v1.yaml - org.opendevstack.apiservice.project.client.api - org.opendevstack.apiservice.project.client.model - org.opendevstack.apiservice.project.client + org.opendevstack.apiservice.projectv1.client.api + org.opendevstack.apiservice.projectv1.client.model + org.opendevstack.apiservice.projectv1.client false false false diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java deleted file mode 100644 index b66bcaeb..00000000 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.opendevstack.apiservice.project.facade; - -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; - -public interface ProjectsFacade { - - GetProjectsResponse getProjects(Integer page, Integer size); -} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java similarity index 78% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java index 6423eac9..e7588f7e 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java @@ -1,11 +1,11 @@ -package org.opendevstack.apiservice.project.controller; +package org.opendevstack.apiservice.projectv1.controller; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.client.api.ProjectsApi; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +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; @@ -13,9 +13,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - -@RestController +@RestController("projectControllerV1") @RequestMapping(ProjectController.API_BASE_PATH) @AllArgsConstructor @Slf4j diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java similarity index 86% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java index 5a038490..0305cfd7 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java @@ -1,11 +1,12 @@ -package org.opendevstack.apiservice.project.controller.advice; +package org.opendevstack.apiservice.projectv1.controller.advice; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.controller.ProjectController; -import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; 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; @@ -13,6 +14,7 @@ import java.util.Map; +@Component("projectExceptionHandlerV1") @RestControllerAdvice(assignableTypes = ProjectController.class) @Slf4j public class ProjectExceptionHandler { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java similarity index 90% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java index a3a78e73..c0691b1e 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.project.exception; +package org.opendevstack.apiservice.projectv1.exception; public enum ErrorKey { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java similarity index 88% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java index 5ce3fe0e..ce982c47 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.project.exception; +package org.opendevstack.apiservice.projectv1.exception; public class ErrorMessage { 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/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java similarity index 64% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java index 43ae84e0..e4ebd6b1 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java @@ -1,14 +1,14 @@ -package org.opendevstack.apiservice.project.facade.impl; +package org.opendevstack.apiservice.projectv1.facade.impl; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.mapper.ProjectMapper; +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("apiProjectFacadeImpl") +@Component("apiProjectFacadeImplV1") @AllArgsConstructor @Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java similarity index 87% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java index b78f66ca..9ce4cd88 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java @@ -1,8 +1,8 @@ -package org.opendevstack.apiservice.project.mapper; +package org.opendevstack.apiservice.projectv1.mapper; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +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.serviceproject.model.ProjectSummary; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; -@Component +@Component("projectMapperV1") public class ProjectMapper { private static final int DEFAULT_PAGE = 0; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java similarity index 91% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java index 50c21f70..ecd3c2e1 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java @@ -1,14 +1,14 @@ -package org.opendevstack.apiservice.project.controller; +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.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; +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; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java similarity index 92% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java index e47542d4..aa07d914 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package org.opendevstack.apiservice.project.controller.advice; +package org.opendevstack.apiservice.projectv1.controller.advice; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.controller.ProjectController; -import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ErrorMessage; +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.ErrorMessage; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BeanPropertyBindingResult; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java similarity index 93% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java index fe7dd9b2..eb69dc4e 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java @@ -1,13 +1,13 @@ -package org.opendevstack.apiservice.project.facade.impl; +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.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; -import org.opendevstack.apiservice.project.mapper.ProjectMapper; +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; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java similarity index 96% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java index c47eb8cb..00c64aeb 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java @@ -1,9 +1,9 @@ -package org.opendevstack.apiservice.project.mapper; +package org.opendevstack.apiservice.projectv1.mapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; From 3b617a21089589deab60b8cdb0fe064ece68b461 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 8 Jul 2026 13:39:34 +0200 Subject: [PATCH 05/11] Refactor ProjectController and ProjectExceptionHandler to handle ConstraintViolationException --- .../controller/ProjectController.java | 3 +- .../advice/ProjectExceptionHandler.java | 38 +++++++++----- .../advice/ProjectExceptionHandlerTest.java | 52 +++++++++++-------- 3 files changed, 56 insertions(+), 37 deletions(-) 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 index e7588f7e..68387325 100644 --- 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 @@ -25,8 +25,7 @@ public class ProjectController implements ProjectsApi { @GetMapping @Override - public ResponseEntity getProjects(@Valid @RequestParam Integer page, - @Valid @RequestParam Integer size) { + public ResponseEntity getProjects(Integer page, Integer size) { GetProjectsResponse response = projectsFacade.getProjects(page, size); response.setLocation(API_BASE_PATH); 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 index 0305cfd7..ae75cc81 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -13,6 +16,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; +import java.util.Optional; @Component("projectExceptionHandlerV1") @RestControllerAdvice(assignableTypes = ProjectController.class) @@ -24,25 +28,22 @@ public class ProjectExceptionHandler { "size", ErrorKey.INVALID_SIZE ); - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException( - MethodArgumentNotValidException ex) { + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Request validation error: {}", ex.getMessage()); - log.warn("Request body validation error: {}", ex.getMessage()); - - FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() - .findFirst() - .orElse(null); + 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 (fieldError != null) { - String field = fieldError.getField(); - - ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); - + if (error.isPresent()) { + ErrorKey key = error.get(); response.setErrorKey(key.getKey()); response.setMessage(key.getMessage()); } else { @@ -63,4 +64,15 @@ public ResponseEntity handleGenericException(Exception ex) 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/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 index aa07d914..caa5099e 100644 --- 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 @@ -1,16 +1,18 @@ 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.ErrorMessage; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.BeanPropertyBindingResult; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -26,43 +28,44 @@ void setUp() { } @Test - void handle_method_argument_not_valid_returns_bad_request_for_invalid_page() { - MethodArgumentNotValidException ex = createValidationException("page", "must be >= 0", 0); + void handle_constraint_violation_returns_bad_request_for_invalid_page() { + ConstraintViolationException ex = createConstraintViolationException("page"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.INVALID_PAGE); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @Test - void handle_method_argument_not_valid_returns_bad_request_for_invalid_size() { - MethodArgumentNotValidException ex = createValidationException("size", "must be >= 20", 0); + void handle_constraint_violation_returns_bad_request_for_invalid_size() { + ConstraintViolationException ex = createConstraintViolationException("size"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.INVALID_SIZE); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @Test - void handle_method_argument_not_valid_returns_bad_request_for_unknown_field() { - MethodArgumentNotValidException ex = createValidationException("unknownField", "invalid value", "bad"); + void handle_constraint_violation_returns_bad_request_for_unknown_field() { + ConstraintViolationException ex = createConstraintViolationException("unknownField"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.BAD_REQUEST); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @@ -82,11 +85,16 @@ void handle_generic_exception_returns_internal_server_error() { // ------- helpers ------- - private MethodArgumentNotValidException createValidationException(String field, String defaultMessage, Object rejectedValue) { - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); - bindingResult.addError(new FieldError("target", field, rejectedValue, false, null, null, defaultMessage)); - MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); - when(ex.getBindingResult()).thenReturn(bindingResult); - return ex; + 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)); } } From 622af69a910a7c841a5611c3eb24ae917360c0e5 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Thu, 9 Jul 2026 10:16:14 +0200 Subject: [PATCH 06/11] Update .gitignore to reflect new package structure for API Projects V1 --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 95a8123f..b254917f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,8 +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/project/api -api-project-v1/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 From 42b047f3bb9961e6ccf5c87537ffd828582297ed Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 15 Jul 2026 09:40:14 +0200 Subject: [PATCH 07/11] Implement pagination error handling and add PageNotFoundException --- api-project-v1/openapi/api-project-v1.yaml | 2 +- .../advice/ProjectExceptionHandler.java | 30 ++++++++ .../projectv1/exception/ErrorKey.java | 3 +- .../projectv1/exception/ErrorMessage.java | 3 +- .../exception/PageNotFoundException.java | 19 ++++++ .../projectv1/mapper/ProjectMapper.java | 18 ++++- .../advice/ProjectExceptionHandlerTest.java | 68 +++++++++++++++++++ .../projectv1/mapper/ProjectMapperTest.java | 16 +++++ 8 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/PageNotFoundException.java diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml index 9a472e44..377dcc96 100644 --- a/api-project-v1/openapi/api-project-v1.yaml +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -37,7 +37,7 @@ paths: schema: type: integer default: 20 - minimum: 20 + minimum: 1 maximum: 1000 description: Size of the page to retrieve. responses: 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 index ae75cc81..181f7c65 100644 --- 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 @@ -7,6 +7,7 @@ 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; @@ -14,6 +15,7 @@ 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; @@ -54,6 +56,34 @@ public ResponseEntity handleConstraintViolationException( 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); 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 index c0691b1e..4993ad17 100644 --- 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 @@ -5,7 +5,8 @@ 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); + INVALID_SIZE("032", ErrorMessage.INVALID_SIZE), + PAGE_NOT_FOUND("033", ErrorMessage.PAGE_NOT_FOUND); private String key; private String 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 index ce982c47..d2ac0351 100644 --- 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 @@ -6,7 +6,8 @@ public class ErrorMessage { 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 greater or equal to 20"; + 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/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java index 9ce4cd88..b17383b1 100644 --- 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 @@ -3,6 +3,8 @@ 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.stereotype.Component; @@ -23,7 +25,15 @@ public GetProjectsResponse toApiResponse(List projects, Integer int totalElements = projects != null ? projects.size() : 0; int totalPages = (int) Math.ceil((double) totalElements / effectiveSize); - boolean isLast = (effectivePage + 1) >= totalPages; + + 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 = projects == null ? List.of() @@ -59,4 +69,8 @@ private ProjectsResponse toProjectsResponse(ProjectSummary source) { 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/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java index caa5099e..a565b99a 100644 --- 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 @@ -8,8 +8,10 @@ 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; @@ -69,6 +71,62 @@ void handle_constraint_violation_returns_bad_request_for_unknown_field() { 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"); @@ -97,4 +155,14 @@ private ConstraintViolationException createConstraintViolationException(String f 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/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java index 00c64aeb..f2a310bd 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -12,6 +13,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class ProjectMapperTest { @@ -145,6 +147,20 @@ void to_api_response_uses_default_size_when_null() { assertThat(result.getMetadata().getSize()).isEqualTo(20); } + @Test + void to_api_response_throws_exception_when_page_is_greater_than_total_pages() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build(), + ProjectSummary.builder().projectKey("P4").build(), + ProjectSummary.builder().projectKey("P5").build() + ); + + assertThrows(PageNotFoundException.class, + () -> sut.toApiResponse(summaries, 5, 2)); + } + @Test void to_api_response_maps_null_status_to_null_string() { ProjectSummary summary = ProjectSummary.builder() From a6fe6a14eab9f80daeb253b4580d0d2da032572e Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 15 Jul 2026 22:51:30 +0200 Subject: [PATCH 08/11] Refactor ProjectMapper and ProjectService to use Spring Data Page for GetProjects API --- .../facade/impl/ProjectsFacadeImpl.java | 7 +- .../projectv1/mapper/ProjectMapper.java | 24 +++--- .../facade/impl/ProjectsFacadeImplTest.java | 31 ++++---- .../projectv1/mapper/ProjectMapperTest.java | 74 ++++++------------- .../mapper/ProjectResponseMapper.java | 4 +- .../service/ProjectService.java | 3 +- .../service/impl/ProjectServiceImpl.java | 16 +++- .../service/impl/ProjectServiceImplTest.java | 46 +++++++----- 8 files changed, 98 insertions(+), 107 deletions(-) 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 index e4ebd6b1..5ce4e481 100644 --- 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 @@ -13,12 +13,17 @@ @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) { - return projectMapper.toApiResponse(projectService.getProjects(), page, 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 index b17383b1..f19d0f9d 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -15,16 +16,13 @@ @Component("projectMapperV1") public class ProjectMapper { - private static final int DEFAULT_PAGE = 0; - private static final int DEFAULT_SIZE = 20; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - public GetProjectsResponse toApiResponse(List projects, Integer page, Integer size) { - int effectivePage = page != null ? page : DEFAULT_PAGE; - int effectiveSize = size != null && size > 0 ? size : DEFAULT_SIZE; - - int totalElements = projects != null ? projects.size() : 0; - int totalPages = (int) Math.ceil((double) totalElements / effectiveSize); + 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( @@ -35,13 +33,9 @@ public GetProjectsResponse toApiResponse(List projects, Integer boolean isLast = isPageZeroCase(effectivePage, totalPages) || (effectivePage + 1) == totalPages; - List pageContent = projects == null - ? List.of() - : projects.stream() - .skip((long) effectivePage * effectiveSize) - .limit(effectiveSize) - .map(this::toProjectsResponse) - .toList(); + List pageContent = projectPage.getContent().stream() + .map(this::toProjectsResponse) + .toList(); GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() .page(effectivePage) 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 index eb69dc4e..341d7e37 100644 --- 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 @@ -11,6 +11,9 @@ 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; @@ -47,51 +50,52 @@ void get_projects_returns_response_from_mapper() { .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()).thenReturn(summaries); - when(projectMapper.toApiResponse(summaries, 0, 20)).thenReturn(expectedResponse); + 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(); - verify(projectMapper).toApiResponse(summaries, 0, 20); + verify(projectService).getProjects(0, 20); + verify(projectMapper).toApiResponse(summaryPage); } @Test void get_projects_returns_empty_list_when_no_projects() { - List emptySummaries = List.of(); + 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()).thenReturn(emptySummaries); - when(projectMapper.toApiResponse(emptySummaries, 0, 20)).thenReturn(emptyResponse); + 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(); - verify(projectMapper).toApiResponse(emptySummaries, 0, 20); + verify(projectService).getProjects(0, 20); + verify(projectMapper).toApiResponse(emptyPage); } @Test void get_projects_propagates_service_exception() { - when(projectService.getProjects()).thenThrow(new RuntimeException("DB error")); + when(projectService.getProjects(0, 20)).thenThrow(new RuntimeException("DB error")); assertThrows(RuntimeException.class, () -> sut.getProjects(0, 20)); - verify(projectService).getProjects(); + verify(projectService).getProjects(0, 20); } @Test @@ -118,6 +122,7 @@ void get_projects_returns_multiple_projects_with_all_fields() { .updatedAt(now) .build() ); + Page summaryPage = new PageImpl<>(summaries, PageRequest.of(0, 20), 2); GetProjectsResponse expectedResponse = new GetProjectsResponse() .projects(List.of( @@ -126,8 +131,8 @@ void get_projects_returns_multiple_projects_with_all_fields() { )) .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(2).totalPages(1).last(true)); - when(projectService.getProjects()).thenReturn(summaries); - when(projectMapper.toApiResponse(summaries, 0, 20)).thenReturn(expectedResponse); + when(projectService.getProjects(0, 20)).thenReturn(summaryPage); + when(projectMapper.toApiResponse(summaryPage)).thenReturn(expectedResponse); GetProjectsResponse result = sut.getProjects(0, 20); 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 index f2a310bd..b09bd1fd 100644 --- 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 @@ -7,6 +7,9 @@ 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; @@ -39,7 +42,8 @@ void to_api_response_maps_projects_correctly() { .updatedAt(updatedAt) .build(); - GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + 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); @@ -55,18 +59,9 @@ void to_api_response_maps_projects_correctly() { } @Test - void to_api_response_returns_empty_list_when_projects_are_null() { - GetProjectsResponse result = sut.toApiResponse(null, 0, 20); - - assertThat(result).isNotNull(); - assertThat(result.getProjects()).isEmpty(); - assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); - assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); - } - - @Test - void to_api_response_returns_empty_list_when_projects_list_is_empty() { - GetProjectsResponse result = sut.toApiResponse(List.of(), 0, 20); + 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(); @@ -83,7 +78,8 @@ void to_api_response_calculates_metadata_correctly() { ProjectSummary.builder().projectKey("P3").build() ); - GetProjectsResponse result = sut.toApiResponse(summaries, 0, 20); + 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); @@ -96,13 +92,11 @@ void to_api_response_calculates_metadata_correctly() { void to_api_response_paginates_correctly_first_page() { List summaries = List.of( ProjectSummary.builder().projectKey("P1").build(), - ProjectSummary.builder().projectKey("P2").build(), - ProjectSummary.builder().projectKey("P3").build(), - ProjectSummary.builder().projectKey("P4").build(), - ProjectSummary.builder().projectKey("P5").build() + ProjectSummary.builder().projectKey("P2").build() ); - GetProjectsResponse result = sut.toApiResponse(summaries, 0, 2); + 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"); @@ -115,50 +109,24 @@ void to_api_response_paginates_correctly_first_page() { @Test void to_api_response_paginates_correctly_last_page() { List summaries = List.of( - ProjectSummary.builder().projectKey("P1").build(), - ProjectSummary.builder().projectKey("P2").build(), - ProjectSummary.builder().projectKey("P3").build(), - ProjectSummary.builder().projectKey("P4").build(), ProjectSummary.builder().projectKey("P5").build() ); - GetProjectsResponse result = sut.toApiResponse(summaries, 2, 2); + 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_uses_default_page_when_null() { - ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); - - GetProjectsResponse result = sut.toApiResponse(List.of(summary), null, 20); - - assertThat(result.getMetadata().getPage()).isEqualTo(0); - } - - @Test - void to_api_response_uses_default_size_when_null() { - ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); - - GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, null); - - assertThat(result.getMetadata().getSize()).isEqualTo(20); - } - @Test void to_api_response_throws_exception_when_page_is_greater_than_total_pages() { - List summaries = List.of( - ProjectSummary.builder().projectKey("P1").build(), - ProjectSummary.builder().projectKey("P2").build(), - ProjectSummary.builder().projectKey("P3").build(), - ProjectSummary.builder().projectKey("P4").build(), - ProjectSummary.builder().projectKey("P5").build() - ); + // 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(summaries, 5, 2)); + () -> sut.toApiResponse(page)); } @Test @@ -168,7 +136,8 @@ void to_api_response_maps_null_status_to_null_string() { .status(null) .build(); - GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + Page page = new PageImpl<>(List.of(summary), PageRequest.of(0, 20), 1); + GetProjectsResponse result = sut.toApiResponse(page); assertThat(result.getProjects().get(0).getStatus()).isNull(); } @@ -181,7 +150,8 @@ void to_api_response_maps_null_datetime_fields_to_null() { .updatedAt(null) .build(); - GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + 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/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 7a8c1b47..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 @@ -11,6 +11,7 @@ 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 { @@ -27,9 +28,6 @@ public interface ProjectResponseMapper { @Named("mapEntityToSummary") ProjectSummary toProjectSummary(ProjectEntity entity); - @IterableMapping(qualifiedByName = "mapEntityToSummary") - List toProjectSummary(List entities); - @Named("mapStatus") default Status mapStatus(String value) { if (value == null) { 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 fdc91b08..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 @@ -3,6 +3,7 @@ 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; @@ -16,5 +17,5 @@ public interface ProjectService { List findProjectsByName(String projectName); - List getProjects(); + 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 427a9676..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 @@ -9,6 +9,10 @@ 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; @@ -81,9 +85,15 @@ public List findProjectsByName(String projectName) { } @Override - public List getProjects() { - List projects = projectRepository.findAll(); - return projectResponseMapper.toProjectSummary(projects); + 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) { 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 c8c9fbfb..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 @@ -11,6 +11,10 @@ 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; @@ -262,40 +266,44 @@ void get_projects_returns_summaries_when_projects_exist() { .status(Status.PENDING) .build(); - when(projectRepository.findAll()).thenReturn(List.of(entity1, entity2)); - when(projectResponseMapper.toProjectSummary(List.of(entity1, entity2))).thenReturn(List.of(summary1, summary2)); + Page entityPage = new PageImpl<>(List.of(entity1, entity2), PageRequest.of(0, 20), 2); - List result = projectService.getProjects(); + 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.size()); - assertEquals("PROJ01", result.get(0).getProjectKey()); - assertEquals(Status.RUNNING, result.get(0).getStatus()); - assertEquals("PROJ02", result.get(1).getProjectKey()); - assertEquals(Status.PENDING, result.get(1).getStatus()); - verify(projectRepository).findAll(); - verify(projectResponseMapper).toProjectSummary(List.of(entity1, entity2)); + 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() { - when(projectRepository.findAll()).thenReturn(List.of()); - when(projectResponseMapper.toProjectSummary(List.of())).thenReturn(List.of()); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + + when(projectRepository.findAll(any(Pageable.class))).thenReturn(emptyPage); - List result = projectService.getProjects(); + Page result = projectService.getProjects(0, 20); assertNotNull(result); - assertEquals(0, result.size()); - verify(projectRepository).findAll(); - verify(projectResponseMapper).toProjectSummary(List.of()); + assertEquals(0, result.getContent().size()); + verify(projectRepository).findAll(any(Pageable.class)); } @Test void get_projects_propagates_repository_exception() { - when(projectRepository.findAll()).thenThrow(new RuntimeException("DB connection error")); + when(projectRepository.findAll(any(Pageable.class))).thenThrow(new RuntimeException("DB connection error")); - assertThrows(RuntimeException.class, () -> projectService.getProjects()); + assertThrows(RuntimeException.class, () -> projectService.getProjects(0, 20)); - verify(projectRepository).findAll(); + verify(projectRepository).findAll(any(Pageable.class)); } } From 629827a9e1943f1170f141aff94672c75389fe9d Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Tue, 21 Jul 2026 14:19:08 +0200 Subject: [PATCH 09/11] Bump version to 0.0.4 in pom.xml --- api-project-v1/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml index e3a18fba..8c6dde1a 100644 --- a/api-project-v1/pom.xml +++ b/api-project-v1/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-project-v1 From ef56e04ca840d1764b946ca6b8d365676f9c1379 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 22 Jul 2026 10:02:44 +0200 Subject: [PATCH 10/11] Update API documentation to include 400 error response for invalid requests --- api-project-v1/openapi/api-project-v1.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml index 377dcc96..39daeb86 100644 --- a/api-project-v1/openapi/api-project-v1.yaml +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -47,8 +47,14 @@ paths: application/json: schema: $ref: '#/components/schemas/GetProjectsResponse' + '400': + description: Invalid request or validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' '404': - description: Project not found. + description: Page not found. content: application/json: schema: From 64ca8fd2dcb6978430d4e7bb3cdd990ae3afc8f4 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 22 Jul 2026 13:47:08 +0200 Subject: [PATCH 11/11] Refactor ProjectMapper and ProjectsFacadeImpl to improve project response mapping and metadata handling --- .../facade/impl/ProjectsFacadeImpl.java | 45 ++++++- .../projectv1/mapper/ProjectMapper.java | 70 +++------- .../facade/impl/ProjectsFacadeImplTest.java | 102 +++++++++++---- .../projectv1/mapper/ProjectMapperTest.java | 122 +++++------------- 4 files changed, 176 insertions(+), 163 deletions(-) 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 index 5ce4e481..c0be36f3 100644 --- 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 @@ -2,12 +2,20 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +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.projectv1.facade.ProjectsFacade; import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; +import java.util.List; + @Component("apiProjectFacadeImplV1") @AllArgsConstructor @Slf4j @@ -24,6 +32,41 @@ public class ProjectsFacadeImpl implements ProjectsFacade { 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)); + + Page projectsPage = projectService.getProjects(page, size); + + List pageContent = projectMapper.toProjectsResponse(projectsPage.getContent()); + GetProjectsResponseMetadata metadata = buildMetadata(projectsPage); + + return new GetProjectsResponse() + .projects(pageContent) + .metadata(metadata); + } + + private GetProjectsResponseMetadata buildMetadata(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; + + return new GetProjectsResponseMetadata() + .page(effectivePage) + .size(effectiveSize) + .totalElements(totalElements) + .totalPages(totalPages) + .last(isLast); + } + + private boolean isPageZeroCase(int effectivePage, int totalPages) { + return effectivePage == 0 && totalPages == 0; } } 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 index f19d0f9d..c868e1ca 100644 --- 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 @@ -1,70 +1,40 @@ package org.opendevstack.apiservice.projectv1.mapper; -import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; 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.opendevstack.apiservice.serviceproject.model.Status; import org.springframework.stereotype.Component; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.List; +@Mapper(componentModel = "spring") @Component("projectMapperV1") -public class ProjectMapper { +public interface ProjectMapper { - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + 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(); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "createdAt", target = "createdAt", qualifiedByName = "formatDateTime") + @Mapping(source = "updatedAt", target = "updatedAt", qualifiedByName = "formatDateTime") + ProjectsResponse toProjectsResponse(ProjectSummary projectSummary); - 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(); + List toProjectsResponse(List projectSummaryList); - 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())); + @Named("mapStatus") + default String mapStatus(Status status) { + if (status == null) { + return null; + } + return status.name(); } - private String formatDateTime(OffsetDateTime dateTime) { + @Named("formatDateTime") + default 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/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java index 341d7e37..561d19eb 100644 --- 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 @@ -5,8 +5,8 @@ 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.exception.PageNotFoundException; import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; @@ -40,7 +40,7 @@ void setUp() { } @Test - void get_projects_returns_response_from_mapper() { + void get_projects_returns_response_built_from_mapper_and_metadata() { List summaries = List.of( ProjectSummary.builder() .projectKey("PROJ01") @@ -51,41 +51,41 @@ void get_projects_returns_response_from_mapper() { .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)); + List mappedProjects = List.of(new ProjectsResponse().projectKey("PROJ01")); when(projectService.getProjects(0, 20)).thenReturn(summaryPage); - when(projectMapper.toApiResponse(summaryPage)).thenReturn(expectedResponse); + when(projectMapper.toProjectsResponse(summaries)).thenReturn(mappedProjects); GetProjectsResponse result = sut.getProjects(0, 20); assertThat(result).isNotNull(); assertThat(result.getProjects()).hasSize(1); assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getMetadata().getPage()).isEqualTo(0); + assertThat(result.getMetadata().getSize()).isEqualTo(20); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(1); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(1); + assertThat(result.getMetadata().getLast()).isTrue(); verify(projectService).getProjects(0, 20); - verify(projectMapper).toApiResponse(summaryPage); + verify(projectMapper).toProjectsResponse(summaries); } @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); + when(projectMapper.toProjectsResponse(List.of())).thenReturn(List.of()); GetProjectsResponse result = sut.getProjects(0, 20); assertThat(result).isNotNull(); assertThat(result.getProjects()).isEmpty(); assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); + assertThat(result.getMetadata().getLast()).isTrue(); verify(projectService).getProjects(0, 20); - verify(projectMapper).toApiResponse(emptyPage); + verify(projectMapper).toProjectsResponse(List.of()); } @Test @@ -123,16 +123,13 @@ void get_projects_returns_multiple_projects_with_all_fields() { .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)); + List mappedProjects = List.of( + new ProjectsResponse().projectKey("PROJ01").status("RUNNING"), + new ProjectsResponse().projectKey("PROJ02").status("PENDING") + ); when(projectService.getProjects(0, 20)).thenReturn(summaryPage); - when(projectMapper.toApiResponse(summaryPage)).thenReturn(expectedResponse); + when(projectMapper.toProjectsResponse(summaries)).thenReturn(mappedProjects); GetProjectsResponse result = sut.getProjects(0, 20); @@ -140,4 +137,65 @@ void get_projects_returns_multiple_projects_with_all_fields() { assertThat(result.getMetadata().getTotalElements()).isEqualTo(2); assertThat(result.getMetadata().getLast()).isTrue(); } + + @Test + void get_projects_uses_default_page_when_null() { + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + + when(projectService.getProjects(0, 20)).thenReturn(page); + when(projectMapper.toProjectsResponse(List.of())).thenReturn(List.of()); + + GetProjectsResponse result = sut.getProjects(null, 20); + + assertThat(result).isNotNull(); + verify(projectService).getProjects(0, 20); + } + + @Test + void get_projects_uses_default_size_when_null() { + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + + when(projectService.getProjects(0, 20)).thenReturn(page); + when(projectMapper.toProjectsResponse(List.of())).thenReturn(List.of()); + + GetProjectsResponse result = sut.getProjects(0, null); + + assertThat(result).isNotNull(); + verify(projectService).getProjects(0, 20); + } + + @Test + void get_projects_throws_page_not_found_when_page_exceeds_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); + + when(projectService.getProjects(5, 2)).thenReturn(page); + when(projectMapper.toProjectsResponse(List.of())).thenReturn(List.of()); + + assertThrows(PageNotFoundException.class, + () -> sut.getProjects(5, 2)); + + verify(projectService).getProjects(5, 2); + } + + @Test + void get_projects_metadata_is_not_last_for_first_page_when_multiple_pages_exist() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build() + ); + Page summaryPage = new PageImpl<>(summaries, PageRequest.of(0, 2), 5); + List mappedProjects = List.of( + new ProjectsResponse().projectKey("P1"), + new ProjectsResponse().projectKey("P2") + ); + + when(projectService.getProjects(0, 2)).thenReturn(summaryPage); + when(projectMapper.toProjectsResponse(summaries)).thenReturn(mappedProjects); + + GetProjectsResponse result = sut.getProjects(0, 2); + + assertThat(result.getMetadata().getLast()).isFalse(); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(3); + } } 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 index b09bd1fd..00d23aa0 100644 --- 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 @@ -2,33 +2,28 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.mapstruct.factory.Mappers; 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; + private ProjectMapper projectMapper; @BeforeEach void setUp() { - sut = new ProjectMapper(); + projectMapper = Mappers.getMapper(ProjectMapper.class); } @Test - void to_api_response_maps_projects_correctly() { + void to_projects_response_maps_single_project_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); @@ -42,118 +37,65 @@ void to_api_response_maps_projects_correctly() { .updatedAt(updatedAt) .build(); - Page page = new PageImpl<>(List.of(summary), PageRequest.of(0, 20), 1); - GetProjectsResponse result = sut.toApiResponse(page); + ProjectsResponse result = projectMapper.toProjectsResponse(summary); 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(); + assertThat(result.getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getProjectName()).isEqualTo("Project One"); + assertThat(result.getProjectFlavor()).isEqualTo("AMP"); + assertThat(result.getLocation()).isEqualTo("eu"); + assertThat(result.getStatus()).isEqualTo("RUNNING"); + assertThat(result.getCreatedAt()).isNotNull(); + assertThat(result.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() { + void to_projects_response_maps_list_correctly() { List summaries = List.of( - ProjectSummary.builder().projectKey("P5").build() + ProjectSummary.builder().projectKey("P1").status(Status.RUNNING).build(), + ProjectSummary.builder().projectKey("P2").status(Status.PENDING).build() ); - Page page = new PageImpl<>(summaries, PageRequest.of(2, 2), 5); - GetProjectsResponse result = sut.toApiResponse(page); + List result = projectMapper.toProjectsResponse(summaries); - assertThat(result.getProjects()).hasSize(1); - assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P5"); - assertThat(result.getMetadata().getLast()).isTrue(); + assertThat(result).hasSize(2); + assertThat(result.get(0).getProjectKey()).isEqualTo("P1"); + assertThat(result.get(0).getStatus()).isEqualTo("RUNNING"); + assertThat(result.get(1).getProjectKey()).isEqualTo("P2"); + assertThat(result.get(1).getStatus()).isEqualTo("PENDING"); } @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); + void to_projects_response_returns_empty_list_for_empty_input() { + List result = projectMapper.toProjectsResponse(List.of()); - assertThrows(PageNotFoundException.class, - () -> sut.toApiResponse(page)); + assertThat(result).isNotNull(); + assertThat(result).isEmpty(); } @Test - void to_api_response_maps_null_status_to_null_string() { + void to_projects_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); + ProjectsResponse result = projectMapper.toProjectsResponse(summary); - assertThat(result.getProjects().get(0).getStatus()).isNull(); + assertThat(result.getStatus()).isNull(); } @Test - void to_api_response_maps_null_datetime_fields_to_null() { + void to_projects_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); + ProjectsResponse result = projectMapper.toProjectsResponse(summary); - assertThat(result.getProjects().get(0).getCreatedAt()).isNull(); - assertThat(result.getProjects().get(0).getUpdatedAt()).isNull(); + assertThat(result.getCreatedAt()).isNull(); + assertThat(result.getUpdatedAt()).isNull(); } }