diff --git a/api/pmt.yml b/api/pmt.yml index e29a938..0a6419a 100644 --- a/api/pmt.yml +++ b/api/pmt.yml @@ -46,6 +46,25 @@ components: plannedEnd: type: string format: date-time + UpdateProjectDTO: + type: object + properties: + name: + type: string + goal: + type: string + customerId: + type: integer + format: int64 + administratorId: + type: integer + format: int64 + start: + type: string + format: date-time + plannedEnd: + type: string + format: date-time CreatedProjectDTO: type: object properties: @@ -192,8 +211,7 @@ paths: $ref: "#/components/responses/InternalError" 503: $ref: "#/components/responses/ServiceUnavailable" - - + /project/{id}: post: operationId: "addEmployee" @@ -225,7 +243,36 @@ paths: $ref: "#/components/responses/InternalError" 503: $ref: "#/components/responses/ServiceUnavailable" - + put: + operationId: "updateProject" + description: "Updates a project" + parameters: + - in: path + name: id + schema: + type: integer + format: int64 + required: true + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProjectDTO" + responses: + 204: + description: "Project updated successfully" + 401: + $ref: "#/components/responses/Unauthorized" + 404: + $ref: "#/components/responses/NotFound" + 409: + $ref: "#/components/responses/Conflict" + 422: + $ref: "#/components/responses/UnprocessableContent" + 500: + $ref: "#/components/responses/InternalError" + 503: + $ref: "#/components/responses/ServiceUnavailable" delete: operationId: "deleteProject" description: "Delete a specific Project" @@ -242,11 +289,27 @@ paths: 401: $ref: "#/components/responses/Unauthorized" 404: - description: "Project not found" - content: - text/plain: - schema: - type: string + $ref: "#/components/responses/NotFound" + 500: + $ref: "#/components/responses/InternalError" + /project/{id}/completed: + post: + operationId: "completeProject" + description: "Complete a specific project" + parameters: + - in: path + name: id + schema: + type: integer + format: int64 + required: true + responses: + 204: + description: "Completed" + 401: + $ref: "#/components/responses/Unauthorized" + 404: + $ref: "#/components/responses/NotFound" 500: $ref: "#/components/responses/InternalError" diff --git a/src/main/java/de/hmmh/pmt/ApiController.java b/src/main/java/de/hmmh/pmt/ApiController.java index 6c57d91..c29c60b 100644 --- a/src/main/java/de/hmmh/pmt/ApiController.java +++ b/src/main/java/de/hmmh/pmt/ApiController.java @@ -5,20 +5,20 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.hmmh.pmt.db.*; import de.hmmh.pmt.dtos.*; import de.hmmh.pmt.employee.ApiClientFactory; -import de.hmmh.pmt.employee.api.EmployeeControllerApi; import de.hmmh.pmt.employee.dtos.EmployeeResponseDTO; import de.hmmh.pmt.oas.DefaultApi; +import de.hmmh.pmt.util.ApiTools; import de.hmmh.pmt.util.Mapper; +import de.hmmh.pmt.util.Validator; import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; -import java.time.ZoneOffset; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -30,6 +30,10 @@ public class ApiController implements DefaultApi { @Autowired private ApiClientFactory apiClientFactory; @Autowired + private Validator validator; + @Autowired + private ApiTools apiTools; + @Autowired private ProjectRepository projectRepository; @Autowired AllocationRepository allocationRepository; @@ -68,26 +72,61 @@ public class ApiController implements DefaultApi { return ResponseEntity.ok(response); } + @Override + public ResponseEntity updateProject(Long id, UpdateProjectDTO body) { + Optional optionalProject = projectRepository.findById(id); + if (optionalProject.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + Project project = optionalProject.get(); + + if (!project.getName().equals(body.getName()) && projectRepository.existsByName(body.getName())) { + return new ResponseEntity<>(HttpStatus.CONFLICT); + } + + if (project.getAdministratorId().equals(body.getAdministratorId())) { + HttpStatus status = apiTools.checkEmployeeExists(body.getAdministratorId()).status(); + if (status != HttpStatus.OK) { + return new ResponseEntity<>(status); + } + } + + Project finalProject = mapper.map(project, body); + if (!validator.isValidProject(project)) { + return new ResponseEntity<>(HttpStatus.UNPROCESSABLE_ENTITY); + } + + if (validator.areAllocationTimeRangesOverlapping( + project.getStart(), + project.getPlannedEnd(), + allocationRepository + .findAllByProjectId(finalProject.getId()) + .stream() + .map(Allocation::getEmployeeId) + .flatMap(employeeId -> allocationRepository.findAllByEmployeeId(employeeId).stream()) + .filter(employeeAllocation -> employeeAllocation.getProjectId().equals(finalProject.getId())) + .toList() + )) { + return new ResponseEntity<>(HttpStatus.CONFLICT); + } + + projectRepository.save(project); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + @Override public ResponseEntity createProject(CreateProjectDTO body) { if (projectRepository.existsByName(body.getName())) { return new ResponseEntity<>(HttpStatus.CONFLICT); } - try { - apiClientFactory.getEmployeeApi().findById(body.getAdministratorId()); - } catch (HttpClientErrorException exception) { - return new ResponseEntity<>( - exception.getStatusCode().equals(HttpStatus.NOT_FOUND) - ? HttpStatus.NOT_FOUND - : HttpStatus.SERVICE_UNAVAILABLE - ); - } catch (RestClientException exception) { - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + HttpStatus status = apiTools.checkEmployeeExists(body.getAdministratorId()).status(); + if (status != HttpStatus.OK) { + return new ResponseEntity<>(status); } Project project = mapper.map(body); - if (!project.isValid()) { + if (!validator.isValidProject(project)) { return new ResponseEntity<>(HttpStatus.UNPROCESSABLE_ENTITY); } projectRepository.save(project); @@ -104,16 +143,13 @@ public class ApiController implements DefaultApi { } Project project = optionalProject.get(); - EmployeeResponseDTO employee; - try { - employee = apiClientFactory.getEmployeeApi().findById(body.getEmployeeId()); - } catch (HttpClientErrorException exception) { - return new ResponseEntity<>(exception.getStatusCode().equals(HttpStatus.NOT_FOUND) - ? HttpStatus.NOT_FOUND - : HttpStatus.SERVICE_UNAVAILABLE); - } catch (RestClientException exception) { - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + + ApiTools.CheckEmployeeRecord employeeRecord = apiTools.checkEmployeeExists(body.getEmployeeId()); + if (employeeRecord.status() != HttpStatus.OK) { + return new ResponseEntity<>(employeeRecord.status()); } + EmployeeResponseDTO employee = employeeRecord.employee(); + if (employee.getSkillSet() .stream() @@ -121,16 +157,11 @@ public class ApiController implements DefaultApi { return new ResponseEntity<>(HttpStatus.UNPROCESSABLE_ENTITY); } - long start = project.getStart().toEpochSecond(ZoneOffset.UTC); - long plannedEnd = project.getPlannedEnd().toEpochSecond(ZoneOffset.UTC); - List allocations = allocationRepository.findAllByEmployeeId(body.getEmployeeId()); - if (allocations.stream() - .map(Allocation::getProject) - .anyMatch(allocatedProject -> { - long allocatedStart = allocatedProject.getStart().toEpochSecond(ZoneOffset.UTC); - long allocatedPlannedEnd = allocatedProject.getPlannedEnd().toEpochSecond(ZoneOffset.UTC); - return Math.max(start, allocatedStart) <= Math.min(plannedEnd, allocatedPlannedEnd); - })) { + if (validator.areAllocationTimeRangesOverlapping( + project.getStart(), + project.getPlannedEnd(), + allocationRepository.findAllByEmployeeId(body.getEmployeeId()) + )) { return new ResponseEntity<>(HttpStatus.UNPROCESSABLE_ENTITY); } @@ -143,6 +174,19 @@ public class ApiController implements DefaultApi { return new ResponseEntity<>(HttpStatus.NO_CONTENT); } + @Override + public ResponseEntity completeProject(Long id) { + Optional optionalProject = projectRepository.findById(id); + if (optionalProject.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + Project project = optionalProject.get(); + + project.setRealEnd(LocalDateTime.now()); + projectRepository.save(project); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + @Override public ResponseEntity removeEmployeeFromProject(Long id, Long employeeId){ Optional allocation = allocationRepository.findById(new AllocationId(id, employeeId)); diff --git a/src/main/java/de/hmmh/pmt/db/Project.java b/src/main/java/de/hmmh/pmt/db/Project.java index 0a70189..2f973e6 100644 --- a/src/main/java/de/hmmh/pmt/db/Project.java +++ b/src/main/java/de/hmmh/pmt/db/Project.java @@ -47,16 +47,4 @@ public class Project { private LocalDateTime plannedEnd; private LocalDateTime realEnd; // Cant be named just "end" because it's and SQL Keyword - - - public boolean isValid() { - Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); - Set> violations = validator.validate(this); - - return violations.isEmpty() && - plannedEnd.isAfter(start) && - (realEnd == null || realEnd.isAfter(start)); - } - } - diff --git a/src/main/java/de/hmmh/pmt/util/ApiTools.java b/src/main/java/de/hmmh/pmt/util/ApiTools.java new file mode 100644 index 0000000..355a820 --- /dev/null +++ b/src/main/java/de/hmmh/pmt/util/ApiTools.java @@ -0,0 +1,34 @@ +package de.hmmh.pmt.util; + +import de.hmmh.pmt.employee.ApiClientFactory; +import de.hmmh.pmt.employee.dtos.EmployeeResponseDTO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +@Component +public class ApiTools { + + @Autowired + private ApiClientFactory apiClientFactory; + + public record CheckEmployeeRecord(EmployeeResponseDTO employee, HttpStatus status) {} + public CheckEmployeeRecord checkEmployeeExists(long id) { + EmployeeResponseDTO employee; + try { + employee =apiClientFactory.getEmployeeApi().findById(id); + } catch (HttpClientErrorException exception) { + return new CheckEmployeeRecord( + null, + exception.getStatusCode().equals(HttpStatus.NOT_FOUND) + ? HttpStatus.NOT_FOUND + : HttpStatus.SERVICE_UNAVAILABLE + ); + } catch (RestClientException exception) { + return new CheckEmployeeRecord(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + return new CheckEmployeeRecord(employee, HttpStatus.OK); + } +} diff --git a/src/main/java/de/hmmh/pmt/util/Mapper.java b/src/main/java/de/hmmh/pmt/util/Mapper.java index 09ba201..6c13bb7 100644 --- a/src/main/java/de/hmmh/pmt/util/Mapper.java +++ b/src/main/java/de/hmmh/pmt/util/Mapper.java @@ -7,6 +7,7 @@ import de.hmmh.pmt.dtos.Employee; import de.hmmh.pmt.dtos.Qualification ; import de.hmmh.pmt.employee.dtos.EmployeeResponseDTO; import de.hmmh.pmt.employee.dtos.QualificationGetDTO; +import de.hmmh.pmt.dtos.UpdateProjectDTO; import org.springframework.stereotype.Component; import java.util.List; @@ -61,4 +62,14 @@ public class Mapper { return dto; } + + public Project map(Project project, UpdateProjectDTO dto) { + project.setName(dto.getName()); + project.setGoal(dto.getGoal()); + project.setCustomerId(dto.getCustomerId()); + project.setAdministratorId(dto.getAdministratorId()); + project.setStart(dto.getStart()); + project.setPlannedEnd(dto.getPlannedEnd()); + return project; + } } diff --git a/src/main/java/de/hmmh/pmt/util/Validator.java b/src/main/java/de/hmmh/pmt/util/Validator.java new file mode 100644 index 0000000..f496d56 --- /dev/null +++ b/src/main/java/de/hmmh/pmt/util/Validator.java @@ -0,0 +1,48 @@ +package de.hmmh.pmt.util; + +import de.hmmh.pmt.db.Allocation; +import de.hmmh.pmt.db.Project; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; + +@Component +public class Validator { + + public boolean isValidProject(Project project) { + jakarta.validation.Validator validator = Validation + .buildDefaultValidatorFactory() + .getValidator(); + Set> violations = validator.validate(project); + + LocalDateTime start = project.getStart(); + LocalDateTime plannedEnd = project.getPlannedEnd(); + LocalDateTime realEnd = project.getRealEnd(); + + return violations.isEmpty() && + plannedEnd.isAfter(start) && + (realEnd == null || realEnd.isAfter(start)); + } + + public boolean areAllocationTimeRangesOverlapping( + LocalDateTime start, + LocalDateTime plannedEnd, + List allocations + ){ + long startUnix = start.toEpochSecond(ZoneOffset.UTC); + long plannedEndUnix = plannedEnd.toEpochSecond(ZoneOffset.UTC); + return allocations.stream() + .map(Allocation::getProject) + .anyMatch(allocatedProject -> { + long allocatedStart = allocatedProject.getStart().toEpochSecond(ZoneOffset.UTC); + long allocatedPlannedEnd = allocatedProject.getPlannedEnd().toEpochSecond(ZoneOffset.UTC); + return Math.max(startUnix, allocatedStart) <= Math.min(plannedEndUnix, allocatedPlannedEnd); + }); + } + +} diff --git a/src/main/resources/spotbugs-exclude.xml b/src/main/resources/spotbugs-exclude.xml index 6fab22b..df91845 100644 --- a/src/main/resources/spotbugs-exclude.xml +++ b/src/main/resources/spotbugs-exclude.xml @@ -1,12 +1,7 @@ - - - - - - - + + diff --git a/src/test/java/de/hmmh/pmt/IntegrationTest.java b/src/test/java/de/hmmh/pmt/IntegrationTest.java index 26ccee2..0d2656d 100644 --- a/src/test/java/de/hmmh/pmt/IntegrationTest.java +++ b/src/test/java/de/hmmh/pmt/IntegrationTest.java @@ -114,6 +114,15 @@ public abstract class IntegrationTest { medicalResearchProject.setPlannedEnd(LocalDateTime.of(2026, 8, 20, 9, 15)); projects.put("medical-research", medicalResearchProject); + Project futureResearchProject = new Project(); + futureResearchProject.setName("What is the Future?"); + futureResearchProject.setGoal("Develop new Future (possibly through Genocide)."); + futureResearchProject.setCustomerId(95L); + futureResearchProject.setAdministratorId(TEST_EMPLOYEE_A_ID); + futureResearchProject.setStart(LocalDateTime.of(2077, 1, 12, 2, 0)); + futureResearchProject.setPlannedEnd(LocalDateTime.of(3000, 6, 19, 4, 0)); + projects.put("future-research", futureResearchProject); + Project overlappingProjectA = new Project(); overlappingProjectA.setName("Overlap A"); overlappingProjectA.setGoal("A Project That Overlaps with another one for Testing"); @@ -177,6 +186,18 @@ public abstract class IntegrationTest { allocation1ToAiResearch.setRole(TEST_QUALIFICATION_A_ID); allocations.put("1>ai-research", allocation1ToAiResearch); + Allocation allocation1ToMedical = new Allocation(); + allocation1ToMedical.setProject(allProjects.get("medical-research")); + allocation1ToMedical.setEmployeeId(TEST_EMPLOYEE_A_ID); + allocation1ToMedical.setRole(TEST_QUALIFICATION_A_ID); + allocations.put("1>medical-research", allocation1ToMedical); + + Allocation allocation1ToFuture = new Allocation(); + allocation1ToFuture.setProject(allProjects.get("future-research")); + allocation1ToFuture.setEmployeeId(TEST_EMPLOYEE_A_ID); + allocation1ToFuture.setRole(TEST_QUALIFICATION_A_ID); + allocations.put("1>future-research", allocation1ToFuture); + allocationRepository.saveAllAndFlush(allocations.values()); return allocations; } diff --git a/src/test/java/de/hmmh/pmt/project/CompleteTest.java b/src/test/java/de/hmmh/pmt/project/CompleteTest.java new file mode 100644 index 0000000..6f212f1 --- /dev/null +++ b/src/test/java/de/hmmh/pmt/project/CompleteTest.java @@ -0,0 +1,31 @@ +package de.hmmh.pmt.project; + +import de.hmmh.pmt.IntegrationTest; +import de.hmmh.pmt.db.Project; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class CompleteTest extends IntegrationTest { + + @Test + void shouldReturnNotFoundWhenProjectDoesntExist() throws Exception { + this.mvc + .perform(post(baseUri + "/project/0/completed")) + .andExpect(status().isNotFound()); + } + + @Test + void successfullyComplete() throws Exception { + + Map allProjects = createTestProjectData(); + Project projectToComplete = allProjects.get("medical-research"); + + this.mvc + .perform(post(baseUri + "/project/" + projectToComplete.getId() + "/completed")) + .andExpect(status().isNoContent()); + } +} diff --git a/src/test/java/de/hmmh/pmt/project/EditTest.java b/src/test/java/de/hmmh/pmt/project/EditTest.java new file mode 100644 index 0000000..7aedbc9 --- /dev/null +++ b/src/test/java/de/hmmh/pmt/project/EditTest.java @@ -0,0 +1,137 @@ +package de.hmmh.pmt.project; + +import de.hmmh.pmt.IntegrationTest; +import de.hmmh.pmt.db.Allocation; +import de.hmmh.pmt.db.Project; +import de.hmmh.pmt.dtos.UpdateProjectDTO; +import de.hmmh.pmt.employee.dtos.EmployeeResponseDTO; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.client.HttpClientErrorException; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class EditTest extends IntegrationTest { + + @Test + void successfullyRenamed() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenReturn(new EmployeeResponseDTO()); + + Map allProjects = createTestProjectData(); + Project projectToEdit = allProjects.get("medical-research"); + + UpdateProjectDTO dto = getDto(projectToEdit); + dto.setName("Mafia Drug Ring"); + + this.mvc + .perform(getRequest(projectToEdit.getId(), dto)) + .andExpect(status().isNoContent()); + } + + @Test + void shouldReturnNotFoundWhenProjectDoesntExist() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenReturn(new EmployeeResponseDTO()); + + Map allProjects = createTestProjectData(); + Project projectToEdit = allProjects.get("medical-research"); + + UpdateProjectDTO dto = getDto(projectToEdit); + + this.mvc + .perform(getRequest(0L, dto)) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturnNotFoundWhenNewAdministratorDoesntExist() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + + Map allProjects = createTestProjectData(); + Project projectToEdit = allProjects.get("medical-research"); + + UpdateProjectDTO dto = getDto(projectToEdit); + + this.mvc + .perform(getRequest(projectToEdit.getId(), dto)) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturnUnprocessableWhenNewTimestampsAreInvalid() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenReturn(new EmployeeResponseDTO()); + + Map allProjects = createTestProjectData(); + Project projectToEdit = allProjects.get("medical-research"); + + UpdateProjectDTO dto = getDto(projectToEdit); + dto.setPlannedEnd(dto.getStart().minusDays(1)); + + this.mvc + .perform(getRequest(projectToEdit.getId(), dto)) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void shouldReturnConflictWhenUpdateWouldCreateNameConflict() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenReturn(new EmployeeResponseDTO()); + + Map allProjects = createTestProjectData(); + Project projectToEdit = allProjects.get("medical-research"); + + UpdateProjectDTO dto = getDto(projectToEdit); + dto.setName(allProjects.get("renewable-energy").getName()); + + this.mvc + .perform(getRequest(projectToEdit.getId(), dto)) + .andExpect(status().isConflict()); + } + + @Test + void shouldReturnConflictWhenAllocationsWouldConflictAfterUpdate() throws Exception { + when(this.mockEmployeeApi.findById(Mockito.anyLong())) + .thenReturn(new EmployeeResponseDTO()); + + Map allProjects = createTestProjectData(); + Map allAllocations = createTestAllocationData(allProjects); + Project projectToEdit = allAllocations.get("1>future-research").getProject(); + + UpdateProjectDTO dto = getDto(projectToEdit); + dto.setStart(LocalDateTime.of(1970, 1, 1, 0, 0)); + dto.setStart(LocalDateTime.of(3000, 1, 1, 0, 0)); + + this.mvc + .perform(getRequest(projectToEdit.getId(), dto)) + .andExpect(status().isConflict()); + } + + private UpdateProjectDTO getDto(Project project) { + UpdateProjectDTO dto = new UpdateProjectDTO(); + dto.setName(project.getName()); + dto.setGoal(project.getGoal()); + dto.setCustomerId(project.getCustomerId()); + dto.setAdministratorId(project.getAdministratorId()); + dto.setStart(project.getStart()); + dto.setPlannedEnd(project.getPlannedEnd()); + return dto; + } + + private RequestBuilder getRequest(long id, UpdateProjectDTO dto) throws Exception { + return MockMvcRequestBuilders + .put(baseUri + "/project/" + id) + .content(this.objectMapper.writeValueAsString(dto)) + .contentType(MediaType.APPLICATION_JSON); + } +}