diff --git a/.gitignore b/.gitignore
index 46a7edea..b254917f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,6 +95,8 @@ api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/a
api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model
api-project/src/main/java/org/opendevstack/apiservice/project/api
api-project/src/main/java/org/opendevstack/apiservice/project/model
+api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/api
+api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/model
api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api
api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model
external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/openapi
diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml
new file mode 100644
index 00000000..9a472e44
--- /dev/null
+++ b/api-project-v1/openapi/api-project-v1.yaml
@@ -0,0 +1,126 @@
+openapi: 3.0.3
+info:
+ title: ODS API Server
+ description: API documentation for ODS (Open DevStack) API Service
+ contact:
+ name: ODS Team
+ version: v0.0.1
+servers:
+ - url: http://{baseurl}/api/pub/v1
+ variables:
+ baseurl:
+ default: localhost:8080
+ description: Development environment
+tags:
+- name: Projects
+ description: API for manage EDP projects.
+paths:
+ /projects:
+ get:
+ tags:
+ - Projects
+ summary: Get all projects filtered by page and size.
+ description: Returns the current status and details for each project in a list filtered by the given page and size.
+ operationId: getProjects
+ parameters:
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 0
+ minimum: 0
+ description: Page number to retrieve.
+ - name: size
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 20
+ minimum: 20
+ maximum: 1000
+ description: Size of the page to retrieve.
+ responses:
+ '200':
+ description: Project information retrieved successfully.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GetProjectsResponse'
+ '404':
+ description: Project not found.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GetProjectsResponse'
+ "401":
+ description: Invalid client token on the request.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+ "403":
+ description: Insufficient permissions for the client to access the resource.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+ '500':
+ description: Internal server error.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GetProjectsResponse'
+components:
+ schemas:
+ RestErrorMessage:
+ properties:
+ message:
+ type: string
+ required:
+ - message
+ ProjectsResponse:
+ type: object
+ properties:
+ projectKey:
+ type: string
+ projectName:
+ type: string
+ projectFlavor:
+ type: string
+ location:
+ type: string
+ status:
+ type: string
+ createdAt:
+ type: string
+ updatedAt:
+ type: string
+ GetProjectsResponse:
+ type: object
+ properties:
+ projects:
+ type: array
+ items:
+ $ref: '#/components/schemas/ProjectsResponse'
+ metadata:
+ type: object
+ properties:
+ page:
+ type: integer
+ size:
+ type: integer
+ totalElements:
+ type: integer
+ totalPages:
+ type: integer
+ last:
+ type: boolean
+ message:
+ type: string
+ error:
+ type: string
+ errorKey:
+ type: string
+ location:
+ type: string
\ No newline at end of file
diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml
new file mode 100644
index 00000000..e3a18fba
--- /dev/null
+++ b/api-project-v1/pom.xml
@@ -0,0 +1,182 @@
+
+ 4.0.0
+
+
+ org.opendevstack.apiservice
+ devstack-api-service
+ 0.0.3
+
+
+ api-project-v1
+ API Projects V1
+ API module for managing EDP projects
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+
+
+
+ org.opendevstack.apiservice
+ service-projects
+ ${project.version}
+
+
+
+ org.opendevstack.apiservice
+ persistence
+ ${project.version}
+
+
+
+ org.opendevstack.apiservice
+ core-security
+ ${project.version}
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+
+ org.openapitools
+ jackson-databind-nullable
+ ${jackson-databind-nullable.version}
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-core
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+
+
+ generate-api-project-v1
+
+ generate
+
+
+ spring
+
+ spring-boot
+ ${project.basedir}/openapi/api-project-v1.yaml
+ org.opendevstack.apiservice.projectv1.client.api
+ org.opendevstack.apiservice.projectv1.client.model
+ org.opendevstack.apiservice.projectv1.client
+ false
+ false
+ false
+ false
+ false
+ false
+
+ true
+ true
+ springdoc
+ true
+ true
+ true
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ true
+
+
+
+
+
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java
new file mode 100644
index 00000000..68387325
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java
@@ -0,0 +1,37 @@
+package org.opendevstack.apiservice.projectv1.controller;
+
+import jakarta.validation.Valid;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.opendevstack.apiservice.projectv1.client.api.ProjectsApi;
+import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController("projectControllerV1")
+@RequestMapping(ProjectController.API_BASE_PATH)
+@AllArgsConstructor
+@Slf4j
+public class ProjectController implements ProjectsApi {
+
+ public static final String API_BASE_PATH = "/api/pub/v1/projects";
+
+ private final ProjectsFacade projectsFacade;
+
+ @GetMapping
+ @Override
+ public ResponseEntity getProjects(Integer page, Integer size) {
+
+ GetProjectsResponse response = projectsFacade.getProjects(page, size);
+ response.setLocation(API_BASE_PATH);
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(response);
+ }
+}
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java
new file mode 100644
index 00000000..ae75cc81
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java
@@ -0,0 +1,78 @@
+package org.opendevstack.apiservice.projectv1.controller.advice;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import jakarta.validation.Path;
+import lombok.extern.slf4j.Slf4j;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.controller.ProjectController;
+import org.opendevstack.apiservice.projectv1.exception.ErrorKey;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+import java.util.Optional;
+
+@Component("projectExceptionHandlerV1")
+@RestControllerAdvice(assignableTypes = ProjectController.class)
+@Slf4j
+public class ProjectExceptionHandler {
+
+ private static final Map FIELD_ERROR_MAP = Map.of(
+ "page", ErrorKey.INVALID_PAGE,
+ "size", ErrorKey.INVALID_SIZE
+ );
+
+ @ExceptionHandler(ConstraintViolationException.class)
+ public ResponseEntity handleConstraintViolationException(
+ ConstraintViolationException ex) {
+ log.warn("Request validation error: {}", ex.getMessage());
+
+ Optional error = ex.getConstraintViolations()
+ .stream()
+ .map(this::formatConstraintViolation)
+ .findFirst();
+
+ GetProjectsResponse response = new GetProjectsResponse();
+ response.setLocation(ProjectController.API_BASE_PATH);
+ response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase());
+
+ if (error.isPresent()) {
+ ErrorKey key = error.get();
+ response.setErrorKey(key.getKey());
+ response.setMessage(key.getMessage());
+ } else {
+ response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey());
+ response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage());
+ }
+
+ return ResponseEntity.badRequest().body(response);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(Exception ex) {
+ log.error("Unexpected error: {}", ex.getMessage(), ex);
+ GetProjectsResponse response = new GetProjectsResponse();
+ response.setLocation(ProjectController.API_BASE_PATH);
+ response.setError(ErrorKey.INTERNAL_ERROR.getMessage());
+ response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey());
+ response.setMessage("An error occurred while processing the request.");
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+ }
+
+ private ErrorKey formatConstraintViolation(ConstraintViolation> violation) {
+ Path path = violation.getPropertyPath();
+
+ String lastNodeName = null;
+ for (Path.Node node : path) {
+ lastNodeName = node.getName();
+ }
+
+ return FIELD_ERROR_MAP.getOrDefault(lastNodeName, ErrorKey.BAD_REQUEST_BODY);
+ }
+}
\ No newline at end of file
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java
new file mode 100644
index 00000000..c0691b1e
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java
@@ -0,0 +1,25 @@
+package org.opendevstack.apiservice.projectv1.exception;
+
+public enum ErrorKey {
+
+ INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR),
+ BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST),
+ INVALID_PAGE("031", ErrorMessage.INVALID_PAGE),
+ INVALID_SIZE("032", ErrorMessage.INVALID_SIZE);
+
+ private String key;
+ private String message;
+
+ ErrorKey(String key, String message) {
+ this.key = key;
+ this.message = message;
+ }
+
+ public String getKey() {
+ return this.key;
+ }
+
+ public String getMessage() {
+ return this.message;
+ }
+}
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java
new file mode 100644
index 00000000..ce982c47
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java
@@ -0,0 +1,14 @@
+package org.opendevstack.apiservice.projectv1.exception;
+
+public class ErrorMessage {
+
+ public static final String BAD_REQUEST = "Bad Request";
+ public static final String INTERNAL_ERROR = "Internal error";
+
+ public static final String INVALID_PAGE = "page must be an integer greater or equal to 0";
+ public static final String INVALID_SIZE = "size must be an integer greater or equal to 20";
+
+ private ErrorMessage() {
+ // prevent instantiation
+ }
+}
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java
new file mode 100644
index 00000000..5362c752
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java
@@ -0,0 +1,8 @@
+package org.opendevstack.apiservice.projectv1.facade;
+
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+
+public interface ProjectsFacade {
+
+ GetProjectsResponse getProjects(Integer page, Integer size);
+}
diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java
new file mode 100644
index 00000000..e4ebd6b1
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java
@@ -0,0 +1,24 @@
+package org.opendevstack.apiservice.projectv1.facade.impl;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper;
+import org.opendevstack.apiservice.serviceproject.service.ProjectService;
+import org.springframework.stereotype.Component;
+
+@Component("apiProjectFacadeImplV1")
+@AllArgsConstructor
+@Slf4j
+public class ProjectsFacadeImpl implements ProjectsFacade {
+
+ private final ProjectService projectService;
+
+ private final ProjectMapper projectMapper;
+
+ @Override
+ 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/projectv1/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java
new file mode 100644
index 00000000..9ce4cd88
--- /dev/null
+++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java
@@ -0,0 +1,62 @@
+package org.opendevstack.apiservice.projectv1.mapper;
+
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata;
+import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse;
+import org.opendevstack.apiservice.serviceproject.model.ProjectSummary;
+import org.springframework.stereotype.Component;
+
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Component("projectMapperV1")
+public class ProjectMapper {
+
+ private static final 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/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java
new file mode 100644
index 00000000..ecd3c2e1
--- /dev/null
+++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java
@@ -0,0 +1,109 @@
+package org.opendevstack.apiservice.projectv1.controller;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata;
+import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse;
+import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ProjectControllerTest {
+
+ @Mock
+ private ProjectsFacade projectsFacade;
+
+ private ProjectController sut;
+ private AutoCloseable mocks;
+
+ @BeforeEach
+ void setup() {
+ mocks = MockitoAnnotations.openMocks(this);
+ sut = new ProjectController(projectsFacade);
+
+ Jwt jwtToken = Jwt.withTokenValue("dummy-token")
+ .claim("appid", UUID.randomUUID().toString())
+ .claim("sub", "test-user")
+ .header("alg", "none")
+ .build();
+ JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ mocks.close();
+ }
+
+ @Test
+ void get_projects_returns_ok_with_projects_list() {
+ ProjectsResponse project = new ProjectsResponse()
+ .projectKey("PROJ01")
+ .projectName("Project One")
+ .projectFlavor("AMP")
+ .location("eu")
+ .status("RUNNING");
+
+ GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata()
+ .page(0)
+ .size(20)
+ .totalElements(1)
+ .totalPages(1)
+ .last(true);
+
+ GetProjectsResponse facadeResponse = new GetProjectsResponse()
+ .projects(List.of(project))
+ .metadata(metadata);
+
+ when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse);
+
+ ResponseEntity result = sut.getProjects(0, 20);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getProjects()).hasSize(1);
+ assertThat(result.getBody().getProjects().get(0).getProjectKey()).isEqualTo("PROJ01");
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ assertThat(result.getBody().getMetadata()).isNotNull();
+ assertThat(result.getBody().getMetadata().getTotalElements()).isEqualTo(1);
+ verify(projectsFacade).getProjects(0, 20);
+ }
+
+ @Test
+ void get_projects_returns_ok_with_empty_list_when_no_projects_exist() {
+ GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata()
+ .page(0)
+ .size(20)
+ .totalElements(0)
+ .totalPages(0)
+ .last(true);
+
+ GetProjectsResponse facadeResponse = new GetProjectsResponse()
+ .projects(List.of())
+ .metadata(metadata);
+
+ when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse);
+
+ ResponseEntity result = sut.getProjects(0, 20);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getProjects()).isEmpty();
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ verify(projectsFacade).getProjects(0, 20);
+ }
+}
diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java
new file mode 100644
index 00000000..caa5099e
--- /dev/null
+++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java
@@ -0,0 +1,100 @@
+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.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ProjectExceptionHandlerTest {
+
+ private ProjectExceptionHandler sut;
+
+ @BeforeEach
+ void setUp() {
+ sut = new ProjectExceptionHandler();
+ }
+
+ @Test
+ void handle_constraint_violation_returns_bad_request_for_invalid_page() {
+ ConstraintViolationException ex = createConstraintViolationException("page");
+
+ ResponseEntity result = sut.handleConstraintViolationException(ex);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase());
+ assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_PAGE.getKey());
+ assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage());
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ }
+
+ @Test
+ void handle_constraint_violation_returns_bad_request_for_invalid_size() {
+ ConstraintViolationException ex = createConstraintViolationException("size");
+
+ ResponseEntity result = sut.handleConstraintViolationException(ex);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase());
+ assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_SIZE.getKey());
+ assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage());
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ }
+
+ @Test
+ void handle_constraint_violation_returns_bad_request_for_unknown_field() {
+ ConstraintViolationException ex = createConstraintViolationException("unknownField");
+
+ ResponseEntity result = sut.handleConstraintViolationException(ex);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase());
+ assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey());
+ assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage());
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ }
+
+ @Test
+ void handle_generic_exception_returns_internal_server_error() {
+ Exception ex = new RuntimeException("Unexpected database failure");
+
+ ResponseEntity result = sut.handleGenericException(ex);
+
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(result.getBody()).isNotNull();
+ assertThat(result.getBody().getError()).isEqualTo(ErrorKey.INTERNAL_ERROR.getMessage());
+ assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INTERNAL_ERROR.getKey());
+ assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request.");
+ assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH);
+ }
+
+ // ------- helpers -------
+
+ private ConstraintViolationException createConstraintViolationException(String fieldName) {
+ Path.Node node = mock(Path.Node.class);
+ when(node.getName()).thenReturn(fieldName);
+
+ Path path = mock(Path.class);
+ when(path.iterator()).thenReturn(List.of(node).iterator());
+
+ ConstraintViolation> violation = mock(ConstraintViolation.class);
+ when(violation.getPropertyPath()).thenReturn(path);
+
+ return new ConstraintViolationException(Set.of(violation));
+ }
+}
diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java
new file mode 100644
index 00000000..eb69dc4e
--- /dev/null
+++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java
@@ -0,0 +1,138 @@
+package org.opendevstack.apiservice.projectv1.facade.impl;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata;
+import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse;
+import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper;
+import org.opendevstack.apiservice.serviceproject.model.ProjectSummary;
+import org.opendevstack.apiservice.serviceproject.model.Status;
+import org.opendevstack.apiservice.serviceproject.service.ProjectService;
+
+import 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/projectv1/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java
new file mode 100644
index 00000000..00c64aeb
--- /dev/null
+++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java
@@ -0,0 +1,173 @@
+package org.opendevstack.apiservice.projectv1.mapper;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse;
+import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse;
+import org.opendevstack.apiservice.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/core/pom.xml b/core/pom.xml
index a0ba5310..3c4cd141 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -134,6 +134,12 @@
${project.version}
+
+ org.opendevstack.apiservice
+ api-project-v1
+ ${project.version}
+
+
org.opendevstack.apiservice
api-project-component-v0
diff --git a/pom.xml b/pom.xml
index f14bc6df..881bcaf7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,6 +61,7 @@
api-project-users
api-project-platform
api-project
+ api-project-v1
api-project-component-v0
diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java
index 199a43ba..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)
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();
+ }
}