From bcd40f7b0f7890c87e09fe43f2cd66afe27aecba Mon Sep 17 00:00:00 2001 From: josh-uk Date: Wed, 8 Jul 2026 08:55:51 +0100 Subject: [PATCH] New approvals flow --- .../ApprovalControllerFunctionalTest.java | 168 +++++++++++++++++ .../controllers/ApprovalControllerTest.java | 151 +++++++++++++++ .../api/controllers/ApprovalController.java | 71 +++++++ .../fact/data/api/dto/ApprovalStatus.java | 19 ++ .../fact/data/api/entities/Approval.java | 74 ++++++++ .../api/repositories/ApprovalRepository.java | 20 ++ .../data/api/services/ApprovalService.java | 102 ++++++++++ .../V1.40__create_approvals_table.sql | 17 ++ .../controllers/ApprovalControllerTest.java | 113 +++++++++++ .../api/services/ApprovalServiceTest.java | 176 ++++++++++++++++++ 10 files changed, 911 insertions(+) create mode 100644 src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/ApprovalControllerFunctionalTest.java create mode 100644 src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalController.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/dto/ApprovalStatus.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/entities/Approval.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/repositories/ApprovalRepository.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalService.java create mode 100644 src/main/resources/db/migration/V1.40__create_approvals_table.sql create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalServiceTest.java diff --git a/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/ApprovalControllerFunctionalTest.java b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/ApprovalControllerFunctionalTest.java new file mode 100644 index 00000000..29bc8a4b --- /dev/null +++ b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/ApprovalControllerFunctionalTest.java @@ -0,0 +1,168 @@ +package uk.gov.hmcts.reform.fact.functional.controllers; + +import io.qameta.allure.Feature; +import io.restassured.response.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; +import uk.gov.hmcts.reform.fact.functional.helpers.AssertionHelper; +import uk.gov.hmcts.reform.fact.functional.helpers.TestDataHelper; +import uk.gov.hmcts.reform.fact.functional.http.HttpClient; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.OK; + +@Feature("Approval Controller") +@DisplayName("Approval Controller") +public final class ApprovalControllerFunctionalTest { + + private static final HttpClient http = new HttpClient(); + private static final List approvalIdsToDelete = new CopyOnWriteArrayList<>(); + + @Test + @DisplayName("POST /approvals/v1 creates approval and GET /approvals/v1 returns it") + void shouldCreateAndReturnApprovalSuccessfully() { + final UUID courtId = TestDataHelper.createCourt(http, "Test Court Approval"); + final UUID userId = TestDataHelper.createUser(http, "test.approval.create"); + final Approval approval = createApproval(courtId, AuditSubjectType.COURT, userId); + + final Response createResponse = http.doPost("/approvals/v1", approval); + AssertionHelper.assertStatus(createResponse, CREATED); + + final UUID approvalId = UUID.fromString(createResponse.jsonPath().getString("id")); + approvalIdsToDelete.add(approvalId); + + assertThat(createResponse.jsonPath().getString("subjectId")) + .as("Created approval should reference the requested court") + .isEqualTo(courtId.toString()); + assertThat(createResponse.jsonPath().getString("subjectType")) + .as("Created approval should reference a court subject") + .isEqualTo(AuditSubjectType.COURT.name()); + assertThat(createResponse.jsonPath().getString("userId")) + .as("Created approval should reference the requested user") + .isEqualTo(userId.toString()); + assertThat(createResponse.jsonPath().getString("lastUpdatedAt")) + .as("Created approval should include a last updated timestamp") + .isNotBlank(); + + final Response getResponse = http.doGet("/approvals/v1"); + AssertionHelper.assertStatus(getResponse, OK); + + final String approvalPath = "find { it.subjectId == '" + courtId + "' && it.subjectType == 'COURT' }"; + assertThat(getResponse.jsonPath().getString(approvalPath + ".name")) + .as("GET approvals should include the created court") + .isNotBlank(); + assertThat(getResponse.jsonPath().getBoolean(approvalPath + ".approved")) + .as("GET approvals should mark the created court as approved") + .isTrue(); + assertThat(getResponse.jsonPath().getString(approvalPath + ".approvalId")) + .as("GET approvals should include the approval ID for deleting") + .isEqualTo(approvalId.toString()); + assertThat(getResponse.jsonPath().getString(approvalPath + ".user.id")) + .as("GET approvals should include the approver user") + .isEqualTo(userId.toString()); + assertThat(getResponse.jsonPath().getString(approvalPath + ".user.email")) + .as("GET approvals should include the approver email") + .startsWith("test.approval.create."); + } + + @Test + @DisplayName("POST /approvals/v1 creates service centre approval") + void shouldCreateServiceCentreApprovalSuccessfully() { + final UUID serviceCentreId = TestDataHelper.createServiceCentre(http, "Test Service Centre Approval"); + final UUID userId = TestDataHelper.createUser(http, "test.approval.service.centre"); + final Approval approval = createApproval(serviceCentreId, AuditSubjectType.SERVICE_CENTRE, userId); + + final Response createResponse = http.doPost("/approvals/v1", approval); + AssertionHelper.assertStatus(createResponse, CREATED); + + final UUID approvalId = UUID.fromString(createResponse.jsonPath().getString("id")); + approvalIdsToDelete.add(approvalId); + + assertThat(createResponse.jsonPath().getString("subjectId")) + .as("Created approval should reference the requested service centre") + .isEqualTo(serviceCentreId.toString()); + assertThat(createResponse.jsonPath().getString("subjectType")) + .as("Created approval should reference a service centre subject") + .isEqualTo(AuditSubjectType.SERVICE_CENTRE.name()); + } + + @Test + @DisplayName("DELETE /approvals/{approvalId}/v1 deletes approval") + void shouldDeleteApprovalSuccessfully() { + final UUID courtId = TestDataHelper.createCourt(http, "Test Court Approval Delete"); + final UUID userId = TestDataHelper.createUser(http, "test.approval.delete"); + final Response createResponse = http.doPost("/approvals/v1", createApproval(courtId, AuditSubjectType.COURT, + userId)); + AssertionHelper.assertStatus(createResponse, CREATED); + + final UUID approvalId = UUID.fromString(createResponse.jsonPath().getString("id")); + approvalIdsToDelete.add(approvalId); + + final Response deleteResponse = http.doDelete("/approvals/" + approvalId + "/v1"); + AssertionHelper.assertStatus(deleteResponse, NO_CONTENT); + approvalIdsToDelete.remove(approvalId); + + final Response secondDeleteResponse = http.doDelete("/approvals/" + approvalId + "/v1"); + AssertionHelper.assertStatus(secondDeleteResponse, NOT_FOUND); + } + + @Test + @DisplayName("POST /approvals/v1 returns 400 for invalid approval") + void shouldReturnBadRequestForInvalidApproval() { + final Approval approval = Approval.builder() + .subjectId(UUID.randomUUID()) + .subjectType(AuditSubjectType.COURT) + .build(); + + final Response response = http.doPost("/approvals/v1", approval); + AssertionHelper.assertStatus(response, BAD_REQUEST); + } + + @Test + @DisplayName("POST /approvals/v1 returns 404 when subject does not exist") + void shouldReturnNotFoundWhenSubjectDoesNotExist() { + final UUID userId = TestDataHelper.createUser(http, "test.approval.missing.subject"); + final Approval approval = createApproval(UUID.randomUUID(), AuditSubjectType.COURT, userId); + + final Response response = http.doPost("/approvals/v1", approval); + AssertionHelper.assertStatus(response, NOT_FOUND); + + assertThat(response.jsonPath().getString("message")) + .as("Error message should mention court not found") + .contains("Court not found"); + } + + @Test + @DisplayName("DELETE /approvals/{approvalId}/v1 returns 404 for unknown approval") + void shouldReturnNotFoundWhenDeletingUnknownApproval() { + final Response response = http.doDelete("/approvals/" + UUID.randomUUID() + "/v1"); + AssertionHelper.assertStatus(response, NOT_FOUND); + } + + private static Approval createApproval(final UUID subjectId, final AuditSubjectType subjectType, + final UUID userId) { + return Approval.builder() + .subjectId(subjectId) + .subjectType(subjectType) + .userId(userId) + .build(); + } + + @AfterAll + static void cleanUp() { + approvalIdsToDelete.forEach(approvalId -> http.doDelete("/approvals/" + approvalId + "/v1")); + http.doDelete("/testing-support/courts/name-prefix/Test Court Approval"); + http.doDelete("/testing-support/service-centres/name-prefix/Test Service Centre Approval"); + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java new file mode 100644 index 00000000..42680899 --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java @@ -0,0 +1,151 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +import tools.jackson.databind.ObjectMapper; +import uk.gov.hmcts.reform.fact.data.api.dto.ApprovalStatus; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.User; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; +import uk.gov.hmcts.reform.fact.data.api.services.ApprovalService; + +import java.util.List; +import java.util.UUID; + +import io.qameta.allure.Feature; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Feature("Approval Controller") +@DisplayName("Approval Controller") +@WebMvcTest(ApprovalController.class) +@AutoConfigureMockMvc(addFilters = false) +class ApprovalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ApprovalService approvalService; + + private final UUID approvalId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + private final UUID subjectId = UUID.fromString("222e4567-e89b-12d3-a456-426614174000"); + private final UUID userId = UUID.fromString("333e4567-e89b-12d3-a456-426614174000"); + + @Test + @DisplayName("GET /approvals/v1 returns approvals successfully") + void getAllApprovalsReturnsSuccessfully() throws Exception { + when(approvalService.getAllApprovalStatuses()).thenReturn(List.of(createApprovalStatus())); + + mockMvc.perform(get("/approvals/v1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].subjectId").value(subjectId.toString())) + .andExpect(jsonPath("$[0].subjectType").value(AuditSubjectType.COURT.name())) + .andExpect(jsonPath("$[0].name").value("Test Court")) + .andExpect(jsonPath("$[0].approved").value(true)) + .andExpect(jsonPath("$[0].approvalId").value(approvalId.toString())) + .andExpect(jsonPath("$[0].userId").value(userId.toString())) + .andExpect(jsonPath("$[0].user.id").value(userId.toString())) + .andExpect(jsonPath("$[0].user.email").value("approver@justice.gov.uk")); + } + + @Test + @DisplayName("POST /approvals/v1 creates approval successfully") + void createApprovalSuccessfully() throws Exception { + Approval approval = createApproval(); + when(approvalService.createApproval(any(Approval.class))).thenReturn(approval); + + mockMvc.perform(post("/approvals/v1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(approval))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(approvalId.toString())); + } + + @Test + @DisplayName("POST /approvals/v1 returns 400 for invalid request body") + void createApprovalWithInvalidBodyReturnsBadRequest() throws Exception { + Approval approval = createApproval(); + approval.setSubjectId(null); + + mockMvc.perform(post("/approvals/v1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(approval))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /approvals/v1 returns 404 when user or subject does not exist") + void createApprovalWhenReferenceDoesNotExistReturnsNotFound() throws Exception { + Approval approval = createApproval(); + when(approvalService.createApproval(any(Approval.class))).thenThrow(new NotFoundException("User not found")); + + mockMvc.perform(post("/approvals/v1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(approval))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("DELETE /approvals/{approvalId}/v1 deletes approval successfully") + void deleteApprovalSuccessfully() throws Exception { + mockMvc.perform(delete("/approvals/{approvalId}/v1", approvalId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("DELETE /approvals/{approvalId}/v1 returns 400 for invalid UUID") + void deleteApprovalWithInvalidUuidReturnsBadRequest() throws Exception { + mockMvc.perform(delete("/approvals/{approvalId}/v1", "invalid-uuid")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("DELETE /approvals/{approvalId}/v1 returns 404 when approval does not exist") + void deleteApprovalWhenApprovalDoesNotExistReturnsNotFound() throws Exception { + doThrow(new NotFoundException("Approval not found")).when(approvalService).deleteApproval(approvalId); + + mockMvc.perform(delete("/approvals/{approvalId}/v1", approvalId)) + .andExpect(status().isNotFound()); + } + + private Approval createApproval() { + return Approval.builder() + .id(approvalId) + .subjectId(subjectId) + .subjectType(AuditSubjectType.COURT) + .userId(userId) + .build(); + } + + private ApprovalStatus createApprovalStatus() { + return new ApprovalStatus( + subjectId, + AuditSubjectType.COURT, + "Test Court", + true, + approvalId, + userId, + User.builder().id(userId).email("approver@justice.gov.uk").build(), + null + ); + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalController.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalController.java new file mode 100644 index 00000000..734db1eb --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalController.java @@ -0,0 +1,71 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +import uk.gov.hmcts.reform.fact.data.api.dto.ApprovalStatus; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.security.SecuredFactRestController; +import uk.gov.hmcts.reform.fact.data.api.services.ApprovalService; +import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidUUID; + +import java.util.List; +import java.util.UUID; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@SecuredFactRestController( + name = "Approval", + description = "Operations related to approvals", + preAuthorize = "@authService.isAdmin()" +) +@RequiredArgsConstructor +@RequestMapping("/approvals") +@SuppressWarnings("java:S4684") +public class ApprovalController { + + private final ApprovalService approvalService; + + @GetMapping("/v1") + @Operation(summary = "Get all approvals") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved approvals") + }) + public ResponseEntity> getAllApprovals() { + return ResponseEntity.ok(approvalService.getAllApprovalStatuses()); + } + + @PostMapping("/v1") + @Operation(summary = "Create an approval") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Successfully created approval"), + @ApiResponse(responseCode = "400", description = "Invalid request"), + @ApiResponse(responseCode = "404", description = "User or subject not found") + }) + public ResponseEntity createApproval(@Valid @RequestBody Approval approval) { + return ResponseEntity.status(HttpStatus.CREATED).body(approvalService.createApproval(approval)); + } + + @DeleteMapping("/{approvalId}/v1") + @Operation(summary = "Delete an approval by ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Successfully deleted approval"), + @ApiResponse(responseCode = "400", description = "Invalid approval ID supplied"), + @ApiResponse(responseCode = "404", description = "Approval not found") + }) + public ResponseEntity deleteApproval( + @Parameter(description = "UUID of the approval", required = true) @ValidUUID @PathVariable String approvalId) { + approvalService.deleteApproval(UUID.fromString(approvalId)); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/dto/ApprovalStatus.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/dto/ApprovalStatus.java new file mode 100644 index 00000000..39953653 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/dto/ApprovalStatus.java @@ -0,0 +1,19 @@ +package uk.gov.hmcts.reform.fact.data.api.dto; + +import uk.gov.hmcts.reform.fact.data.api.entities.User; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; + +import java.time.ZonedDateTime; +import java.util.UUID; + +public record ApprovalStatus( + UUID subjectId, + AuditSubjectType subjectType, + String name, + boolean approved, + UUID approvalId, + UUID userId, + User user, + ZonedDateTime lastUpdatedAt +) { +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/entities/Approval.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/entities/Approval.java new file mode 100644 index 00000000..82e0d83f --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/entities/Approval.java @@ -0,0 +1,74 @@ +package uk.gov.hmcts.reform.fact.data.api.entities; + +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; + +import java.time.ZonedDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.TimeZoneStorage; +import org.hibernate.annotations.TimeZoneStorageType; +import org.hibernate.annotations.UpdateTimestamp; + +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@Builder +@Entity +@Table(name = "approvals") +public class Approval { + + @Schema( + description = "The internal ID - assigned by the server during creation", + accessMode = Schema.AccessMode.READ_ONLY + ) + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + @Schema(description = "The ID of the subject being approved", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + @Column(name = "subject_id") + private UUID subjectId; + + @Schema(description = "The subject type being approved", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + @Enumerated(EnumType.STRING) + private AuditSubjectType subjectType; + + @Schema(description = "The ID of the associated User", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + @Column(name = "user_id") + private UUID userId; + + @Schema(description = "The associated User") + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false) + private User user; + + @Schema(description = "The last update date/time", accessMode = Schema.AccessMode.READ_ONLY) + @TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC) + @UpdateTimestamp + @Setter(AccessLevel.NONE) + private ZonedDateTime lastUpdatedAt; +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/repositories/ApprovalRepository.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/repositories/ApprovalRepository.java new file mode 100644 index 00000000..18d3e209 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/repositories/ApprovalRepository.java @@ -0,0 +1,20 @@ +package uk.gov.hmcts.reform.fact.data.api.repositories; + +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApprovalRepository extends JpaRepository { + Optional findBySubjectIdAndSubjectType(UUID subjectId, AuditSubjectType subjectType); + + @EntityGraph(attributePaths = {"user"}) + List findAll(); +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalService.java new file mode 100644 index 00000000..20ca1452 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalService.java @@ -0,0 +1,102 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import uk.gov.hmcts.reform.fact.data.api.dto.ApprovalStatus; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; +import uk.gov.hmcts.reform.fact.data.api.entities.types.NameAndId; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; +import uk.gov.hmcts.reform.fact.data.api.repositories.ApprovalRepository; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Stream; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApprovalService { + + private final ApprovalRepository approvalRepository; + private final CourtService courtService; + private final ServiceCentreService serviceCentreService; + private final UserService userService; + + public List getAllApprovalStatuses() { + Map approvalsBySubject = approvalRepository.findAll().stream() + .collect(java.util.stream.Collectors.toMap( + approval -> new SubjectKey(approval.getSubjectId(), approval.getSubjectType()), + Function.identity(), + (existing, replacement) -> existing + )); + + return Stream.concat( + toApprovalStatuses(courtService.getAllCourtNameAndIds(), AuditSubjectType.COURT, approvalsBySubject), + toApprovalStatuses( + serviceCentreService.getAllServiceCentreNameAndIds(), + AuditSubjectType.SERVICE_CENTRE, + approvalsBySubject + ) + ).toList(); + } + + public Approval createApproval(Approval approval) { + validateApprovalReferences(approval); + + approval.setId( + approvalRepository.findBySubjectIdAndSubjectType(approval.getSubjectId(), approval.getSubjectType()) + .map(Approval::getId) + .orElse(null) + ); + + return approvalRepository.save(approval); + } + + @Transactional + public void deleteApproval(UUID approvalId) { + if (!approvalRepository.existsById(approvalId)) { + throw new NotFoundException("Approval not found, ID: " + approvalId); + } + + approvalRepository.deleteById(approvalId); + } + + private void validateApprovalReferences(Approval approval) { + userService.getUserById(approval.getUserId()); + + if (AuditSubjectType.COURT.equals(approval.getSubjectType())) { + courtService.getCourtById(approval.getSubjectId()); + } else if (AuditSubjectType.SERVICE_CENTRE.equals(approval.getSubjectType())) { + serviceCentreService.getServiceCentreById(approval.getSubjectId()); + } + } + + private Stream toApprovalStatuses(List subjects, AuditSubjectType subjectType, + Map approvalsBySubject) { + return subjects.stream() + .map(subject -> toApprovalStatus(subject, subjectType, approvalsBySubject)); + } + + private ApprovalStatus toApprovalStatus(NameAndId subject, AuditSubjectType subjectType, + Map approvalsBySubject) { + Approval approval = approvalsBySubject.get(new SubjectKey(subject.id(), subjectType)); + + return new ApprovalStatus( + subject.id(), + subjectType, + subject.name(), + approval != null, + approval == null ? null : approval.getId(), + approval == null ? null : approval.getUserId(), + approval == null ? null : approval.getUser(), + approval == null ? null : approval.getLastUpdatedAt() + ); + } + + private record SubjectKey(UUID subjectId, AuditSubjectType subjectType) { + } +} diff --git a/src/main/resources/db/migration/V1.40__create_approvals_table.sql b/src/main/resources/db/migration/V1.40__create_approvals_table.sql new file mode 100644 index 00000000..f73fc5d9 --- /dev/null +++ b/src/main/resources/db/migration/V1.40__create_approvals_table.sql @@ -0,0 +1,17 @@ +CREATE TABLE approvals ( + id UUID PRIMARY KEY NOT NULL, + subject_id UUID NOT NULL, + subject_type VARCHAR NOT NULL, + user_id UUID NOT NULL, + last_updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE approvals + ADD CONSTRAINT fk_approvals_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE; + +ALTER TABLE approvals + ADD CONSTRAINT uk_approvals_subject + UNIQUE (subject_id, subject_type); diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java new file mode 100644 index 00000000..566541d6 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/ApprovalControllerTest.java @@ -0,0 +1,113 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +import uk.gov.hmcts.reform.fact.data.api.dto.ApprovalStatus; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.User; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; +import uk.gov.hmcts.reform.fact.data.api.services.ApprovalService; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApprovalControllerTest { + + private static final UUID APPROVAL_ID = UUID.randomUUID(); + private static final UUID SUBJECT_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final String INVALID_UUID = "invalid-uuid"; + + private static final String RESPONSE_STATUS_MESSAGE = "Response status does not match"; + private static final String RESPONSE_BODY_MESSAGE = "Response body does not match"; + + @Mock + private ApprovalService approvalService; + + @InjectMocks + private ApprovalController approvalController; + + @Test + void getAllApprovalsReturns200() { + List approvals = List.of(createApprovalStatus()); + when(approvalService.getAllApprovalStatuses()).thenReturn(approvals); + + ResponseEntity> response = approvalController.getAllApprovals(); + + assertThat(response.getStatusCode()).as(RESPONSE_STATUS_MESSAGE).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).as(RESPONSE_BODY_MESSAGE).isEqualTo(approvals); + } + + @Test + void createApprovalReturns201() { + Approval approval = createApproval(); + when(approvalService.createApproval(approval)).thenReturn(approval); + + ResponseEntity response = approvalController.createApproval(approval); + + assertThat(response.getStatusCode()).as(RESPONSE_STATUS_MESSAGE).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).as(RESPONSE_BODY_MESSAGE).isEqualTo(approval); + } + + @Test + void deleteApprovalReturns204() { + ResponseEntity response = approvalController.deleteApproval(APPROVAL_ID.toString()); + + assertThat(response.getStatusCode()).as(RESPONSE_STATUS_MESSAGE).isEqualTo(HttpStatus.NO_CONTENT); + verify(approvalService).deleteApproval(APPROVAL_ID); + } + + @Test + void deleteApprovalPropagatesNotFoundException() { + doThrow(new NotFoundException("Approval not found")).when(approvalService).deleteApproval(APPROVAL_ID); + + assertThrows( + NotFoundException.class, + () -> approvalController.deleteApproval(APPROVAL_ID.toString()) + ); + } + + @Test + void deleteApprovalThrowsIllegalArgumentExceptionForInvalidUuid() { + assertThrows( + IllegalArgumentException.class, + () -> approvalController.deleteApproval(INVALID_UUID) + ); + } + + private Approval createApproval() { + return Approval.builder() + .id(APPROVAL_ID) + .subjectId(SUBJECT_ID) + .subjectType(AuditSubjectType.COURT) + .userId(USER_ID) + .build(); + } + + private ApprovalStatus createApprovalStatus() { + return new ApprovalStatus( + SUBJECT_ID, + AuditSubjectType.COURT, + "Test Court", + true, + APPROVAL_ID, + USER_ID, + User.builder().id(USER_ID).email("approver@justice.gov.uk").build(), + null + ); + } +} diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalServiceTest.java new file mode 100644 index 00000000..7f1f2136 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/ApprovalServiceTest.java @@ -0,0 +1,176 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import uk.gov.hmcts.reform.fact.data.api.dto.ApprovalStatus; +import uk.gov.hmcts.reform.fact.data.api.entities.Approval; +import uk.gov.hmcts.reform.fact.data.api.entities.Court; +import uk.gov.hmcts.reform.fact.data.api.entities.ServiceCentre; +import uk.gov.hmcts.reform.fact.data.api.entities.User; +import uk.gov.hmcts.reform.fact.data.api.entities.types.AuditSubjectType; +import uk.gov.hmcts.reform.fact.data.api.entities.types.NameAndId; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; +import uk.gov.hmcts.reform.fact.data.api.repositories.ApprovalRepository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApprovalServiceTest { + + private static final UUID APPROVAL_ID = UUID.randomUUID(); + private static final UUID SUBJECT_ID = UUID.randomUUID(); + private static final UUID SERVICE_CENTRE_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final String USER_EMAIL = "approver@justice.gov.uk"; + private static final ZonedDateTime LAST_UPDATED_AT = ZonedDateTime.now(); + + @Mock + private ApprovalRepository approvalRepository; + + @Mock + private CourtService courtService; + + @Mock + private ServiceCentreService serviceCentreService; + + @Mock + private UserService userService; + + @InjectMocks + private ApprovalService approvalService; + + @Test + void getAllApprovalStatusesReturnsCourtsAndServiceCentresMarkedWithApprovalState() { + User user = User.builder() + .id(USER_ID) + .email(USER_EMAIL) + .build(); + Approval approval = Approval.builder() + .id(APPROVAL_ID) + .subjectId(SUBJECT_ID) + .subjectType(AuditSubjectType.COURT) + .userId(USER_ID) + .user(user) + .lastUpdatedAt(LAST_UPDATED_AT) + .build(); + when(approvalRepository.findAll()).thenReturn(List.of(approval)); + when(courtService.getAllCourtNameAndIds()).thenReturn(List.of(new NameAndId("Test Court", SUBJECT_ID))); + when(serviceCentreService.getAllServiceCentreNameAndIds()) + .thenReturn(List.of(new NameAndId("Test Service Centre", SERVICE_CENTRE_ID))); + + List result = approvalService.getAllApprovalStatuses(); + + assertThat(result).containsExactly( + new ApprovalStatus( + SUBJECT_ID, + AuditSubjectType.COURT, + "Test Court", + true, + APPROVAL_ID, + USER_ID, + user, + LAST_UPDATED_AT + ), + new ApprovalStatus( + SERVICE_CENTRE_ID, + AuditSubjectType.SERVICE_CENTRE, + "Test Service Centre", + false, + null, + null, + null, + null + ) + ); + } + + @Test + void createApprovalValidatesCourtAndUserBeforeSaving() { + Approval approval = createApproval(AuditSubjectType.COURT); + approval.setId(APPROVAL_ID); + when(userService.getUserById(USER_ID)).thenReturn(new User()); + when(courtService.getCourtById(SUBJECT_ID)).thenReturn(new Court()); + when(approvalRepository.findBySubjectIdAndSubjectType(SUBJECT_ID, AuditSubjectType.COURT)) + .thenReturn(Optional.empty()); + when(approvalRepository.save(any(Approval.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Approval result = approvalService.createApproval(approval); + + assertThat(result.getId()).isNull(); + verify(userService).getUserById(USER_ID); + verify(courtService).getCourtById(SUBJECT_ID); + verify(approvalRepository).save(approval); + } + + @Test + void createApprovalValidatesServiceCentreBeforeSaving() { + when(userService.getUserById(USER_ID)).thenReturn(new User()); + when(serviceCentreService.getServiceCentreById(SUBJECT_ID)).thenReturn(new ServiceCentre()); + when(approvalRepository.findBySubjectIdAndSubjectType(SUBJECT_ID, AuditSubjectType.SERVICE_CENTRE)) + .thenReturn(Optional.empty()); + when(approvalRepository.save(any(Approval.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Approval approval = createApproval(AuditSubjectType.SERVICE_CENTRE); + approvalService.createApproval(approval); + + verify(serviceCentreService).getServiceCentreById(SUBJECT_ID); + } + + @Test + void createApprovalReusesExistingApprovalIdForApprovedSubject() { + Approval existingApproval = createApproval(AuditSubjectType.COURT); + existingApproval.setId(APPROVAL_ID); + Approval approval = createApproval(AuditSubjectType.COURT); + when(userService.getUserById(USER_ID)).thenReturn(new User()); + when(courtService.getCourtById(SUBJECT_ID)).thenReturn(new Court()); + when(approvalRepository.findBySubjectIdAndSubjectType(SUBJECT_ID, AuditSubjectType.COURT)) + .thenReturn(Optional.of(existingApproval)); + when(approvalRepository.save(any(Approval.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Approval result = approvalService.createApproval(approval); + + assertThat(result.getId()).isEqualTo(APPROVAL_ID); + } + + @Test + void deleteApprovalDeletesExistingApproval() { + when(approvalRepository.existsById(APPROVAL_ID)).thenReturn(true); + + approvalService.deleteApproval(APPROVAL_ID); + + verify(approvalRepository).deleteById(APPROVAL_ID); + } + + @Test + void deleteApprovalThrowsNotFoundExceptionWhenApprovalDoesNotExist() { + when(approvalRepository.existsById(APPROVAL_ID)).thenReturn(false); + + NotFoundException exception = assertThrows( + NotFoundException.class, + () -> approvalService.deleteApproval(APPROVAL_ID) + ); + + assertThat(exception.getMessage()).isEqualTo("Approval not found, ID: " + APPROVAL_ID); + } + + private Approval createApproval(AuditSubjectType subjectType) { + return Approval.builder() + .subjectId(SUBJECT_ID) + .subjectType(subjectType) + .userId(USER_ID) + .build(); + } +}