diff --git a/build.gradle b/build.gradle index 59866a62..0d541fcb 100644 --- a/build.gradle +++ b/build.gradle @@ -201,7 +201,7 @@ dependencies { implementation group: 'com.azure.spring', name: 'spring-cloud-azure-starter-storage-blob', version: '7.3.0' - + implementation group: 'tools.jackson.dataformat', name: 'jackson-dataformat-csv' implementation group: 'tools.jackson.datatype', name: 'jackson-datatype-hibernate7' implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0' diff --git a/infrastructure/storage-account.tf b/infrastructure/storage-account.tf index 036d0e7d..b3192ca1 100644 --- a/infrastructure/storage-account.tf +++ b/infrastructure/storage-account.tf @@ -16,6 +16,10 @@ module "storage_account" { { name = "photos", access_type = "container" + }, + { + name = "csv", + access_type = "container" } ] diff --git a/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java new file mode 100644 index 00000000..c0123c6e --- /dev/null +++ b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java @@ -0,0 +1,35 @@ +package uk.gov.hmcts.reform.fact.functional.controllers; + +import io.qameta.allure.Feature; +import io.restassured.response.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.reform.fact.functional.http.HttpClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +@Feature("CSV Controller") +@DisplayName("CSV Controller") +class CsvControllerFunctionalTest { + + private static final String CSV_ENDPOINT = "/csv/"; + private static final String INVALID_TOKEN = "invalid-token"; + private static final HttpClient http = new HttpClient(); + + @Test + @DisplayName("POST /csv/ without bearer token returns 401") + void shouldReturnUnauthorizedWithoutToken() { + final Response response = http.doPost(CSV_ENDPOINT, null, ""); + + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value()); + } + + @Test + @DisplayName("POST /csv/ with invalid bearer token returns 401") + void shouldReturnUnauthorizedWithInvalidToken() { + final Response response = http.doPost(CSV_ENDPOINT, null, INVALID_TOKEN); + + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value()); + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java index 95eec118..6f74f929 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java @@ -9,6 +9,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; + @Configuration @Profile("test") public class AzureBlobConfigurationTestConfiguration { @@ -17,7 +19,10 @@ public class AzureBlobConfigurationTestConfiguration { private BlobServiceClient blobServiceClient; @Mock - private BlobContainerClient containerClient; + private BlobContainerClient photoContainerClient; + + @Mock + private BlobContainerClient csvContainerClient; @Mock private BlobClient blobClient; @@ -27,8 +32,13 @@ public AzureBlobConfigurationTestConfiguration() { } @Bean - public BlobContainerClient blobContainerClient() { - return containerClient; + public BlobContainerClient photoBlobContainerClient() { + return photoContainerClient; + } + + @Bean + public BlobContainerClient csvBlobContainerClient() { + return csvContainerClient; } @Bean @@ -40,4 +50,14 @@ public BlobClient blobClient() { public BlobServiceClient blobServiceClient() { return blobServiceClient; } + + @Bean + public AzureBlobService photoAzureBlobService() { + return new AzureBlobService(photoContainerClient); + } + + @Bean + public AzureBlobService csvAzureBlobService() { + return new AzureBlobService(csvContainerClient); + } } diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java new file mode 100644 index 00000000..643b0e4c --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java @@ -0,0 +1,82 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +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.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient; +import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; +import uk.gov.hmcts.reform.fact.data.api.services.CourtDetailsViewService; +import uk.gov.hmcts.reform.fact.data.api.services.CourtService; + +import java.util.Collections; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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("CSV Controller") +@DisplayName("CSV Controller") +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class CsvControllerTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean(name = "csvAzureBlobService") + private AzureBlobService azureBlobService; + + @MockitoBean + private CourtService courtService; + + @MockitoBean + private CourtDetailsViewService courtDetailsViewService; + + @MockitoBean + private SlackClient slackClient; + + @Test + @DisplayName("POST /csv/ returns 200 when CSV is created and uploaded successfully") + void createAndUploadCsvReturns200() throws Exception { + when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); + when(azureBlobService.uploadFile(anyString(), any())).thenReturn("http://example.com/blob"); + + mvc.perform(post("/csv/")) + .andExpect(status().isOk()); + + verify(azureBlobService, times(1)).uploadFile(anyString(), any()); + } + + @Test + @DisplayName("POST /csv/ returns 502 when Azure upload fails (e.g. container missing)") + void createAndUploadCsvReturns502OnAzureUploadException() throws Exception { + when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); + when(azureBlobService.uploadFile(anyString(), any())).thenThrow(new RuntimeException("Container not found")); + + mvc.perform(post("/csv/")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.message").value("Failed to upload CSV file to Azure Blob Storage")); + } + + @Test + @DisplayName("POST /csv/ returns 500 when CSV creation fails") + void createAndUploadCsvReturns500OnCsvCreationException() throws Exception { + when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("DB error")); + + mvc.perform(post("/csv/")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Failed to create CSV file")); + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java index b56c05ce..9ab90fff 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java @@ -7,14 +7,36 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; +import org.springframework.beans.factory.annotation.Qualifier; + @Configuration @Profile("!test") public class AzureBlobConfiguration { @Bean - public BlobContainerClient blobContainerClient( + public BlobContainerClient photoBlobContainerClient( + BlobServiceClient blobServiceClient, + @Value("${spring.cloud.azure.storage.blob.photo-container-name}") String containerName) { + return blobServiceClient.getBlobContainerClient(containerName); + } + + @Bean + public BlobContainerClient csvBlobContainerClient( BlobServiceClient blobServiceClient, - @Value("${spring.cloud.azure.storage.blob.container-name}") String containerName) { + @Value("${spring.cloud.azure.storage.blob.csv-container-name}") String containerName) { return blobServiceClient.getBlobContainerClient(containerName); } + + @Bean + public AzureBlobService photoAzureBlobService( + @Qualifier("photoBlobContainerClient") BlobContainerClient photoBlobContainerClient) { + return new AzureBlobService(photoBlobContainerClient); + } + + @Bean + public AzureBlobService csvAzureBlobService( + @Qualifier("csvBlobContainerClient") BlobContainerClient csvBlobContainerClient) { + return new AzureBlobService(csvBlobContainerClient); + } } diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java new file mode 100644 index 00000000..f9d606d5 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java @@ -0,0 +1,37 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import uk.gov.hmcts.reform.fact.data.api.security.SecuredFactRestController; +import uk.gov.hmcts.reform.fact.data.api.services.CsvService; + +@SecuredFactRestController( + name = "CSV", + description = "Operations related to CSV file handling" +) +@RequestMapping("/csv") +public class CsvController { + + private final CsvService csvService; + + public CsvController(CsvService csvService) { + this.csvService = csvService; + } + + @PostMapping("/") + @Operation( + summary = "Create and upload CSV file", + description = "Generates a CSV file with court data and uploads it to the configured storage service." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "CSV file created and uploaded successfully"), + @ApiResponse(responseCode = "500", description = "Failed to create CSV file"), + @ApiResponse(responseCode = "502", description = "Failed to upload CSV file to storage") + }) + public void createAndUploadCsv() { + csvService.createAndUploadCsv(); + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java index f336672d..31c1601f 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java @@ -1,13 +1,17 @@ package uk.gov.hmcts.reform.fact.data.api.errorhandling; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CourtResourceNotFoundException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.DuplicatedListItemException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidAreaOfLawException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidDateRangeException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidFileException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidParameterCombinationException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidPostcodeException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; + import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidUUID; import java.time.LocalDateTime; @@ -206,6 +210,27 @@ public ExceptionResponse handle(InvalidAreaOfLawException ex) { return generateExceptionResponse(ex.getMessage()); } + @ExceptionHandler(JsonConvertException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ExceptionResponse handle(JsonConvertException ex) { + log.error("400, JSON conversion error. Details: {}", ex.getMessage()); + return generateExceptionResponse(ex.getMessage()); + } + + @ExceptionHandler(AzureUploadException.class) + @ResponseStatus(HttpStatus.BAD_GATEWAY) + public ExceptionResponse handle(AzureUploadException ex) { + log.error("502, error while uploading CSV to Azure. Details: {}", ex.getMessage()); + return generateExceptionResponse(ex.getMessage()); + } + + @ExceptionHandler(CsvCreationException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ExceptionResponse handle(CsvCreationException ex) { + log.error("500, error while creating CSV file. Details: {}", ex.getMessage()); + return generateExceptionResponse(ex.getMessage()); + } + private ExceptionResponse generateExceptionResponse(String message) { ExceptionResponse response = new ExceptionResponse(); response.setMessage(message); diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java new file mode 100644 index 00000000..741052ef --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java @@ -0,0 +1,7 @@ +package uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions; + +import lombok.experimental.StandardException; + +@StandardException +public class CsvCreationException extends RuntimeException { +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java new file mode 100644 index 00000000..656c6bf1 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java @@ -0,0 +1,7 @@ +package uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions; + +import lombok.experimental.StandardException; + +@StandardException +public class JsonConvertException extends RuntimeException { +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java new file mode 100644 index 00000000..d27db0f7 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java @@ -0,0 +1,57 @@ +package uk.gov.hmcts.reform.fact.data.api.models; + +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +/** + * An implementation of the {@link MultipartFile} interface that wraps a {@link String} content. + * + *

This class is used to represent a file created from a string, which can be then treated as a standard + * multipart file for operations like uploading to cloud storage.

+ */ +@Getter +public class StringMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] bytes; + + /** + * Constructs a new StringMultipartFile. + * + * @param name the name of the file + * @param originalFilename the original filename + * @param contentType the content type of the file + * @param content the string content of the file + */ + public StringMultipartFile(String name, String originalFilename, String contentType, String content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.bytes = content.getBytes(); + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IllegalStateException { + // This implementation is currently empty as it's not required for current use cases, + // but the override is necessary for the interface. + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java index 238fd82a..6da2026f 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java @@ -3,13 +3,13 @@ import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.models.BlobHttpHeaders; -import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; import java.io.IOException; -@Service +@Slf4j public class AzureBlobService { private final BlobContainerClient blobContainerClient; @@ -19,37 +19,41 @@ public AzureBlobService(BlobContainerClient blobContainerClient) { } /** - * Uploads the image in the Azure blob service. + * Uploads a file to an Azure blob container. + * If containerName is provided, it uploads to that container and creates it if it does not exist. * - * @param imageId The identifier of the image. - * @param file The file to upload. - * @return The id linked to the uploaded image. + * @param blobName The name of the blob to create. + * @param file The file to upload. + * @return The URL of the uploaded blob. */ - public String uploadFile(String imageId, MultipartFile file) { - BlobClient blobClient = blobContainerClient.getBlobClient(imageId); + public String uploadFile(String blobName, MultipartFile file) { + BlobClient blobClient = blobContainerClient.getBlobClient(blobName); + uploadToBlob(blobClient, file); + + log.info("Uploaded file {} to {}", file.getOriginalFilename(), blobClient.getBlobUrl()); + + return blobClient.getBlobUrl(); + } + + private void uploadToBlob(BlobClient blobClient, MultipartFile file) { try { blobClient.upload(file.getInputStream(), file.getSize(), true); - BlobHttpHeaders headers = new BlobHttpHeaders() .setContentType(file.getContentType()); - blobClient.setHttpHeaders(headers); - } catch (IOException e) { throw new AzureUploadException("Could not upload provided file to Azure"); } - - return blobClient.getBlobUrl(); } /** - * Delete a blob from the blob store by the imageId. + * Delete a blob from the blob store by the blob name. * - * @param imageId The identifier of the image. + * @param blobName The name of the blob to delete. */ - public void deleteBlob(String imageId) { - BlobClient blobClient = blobContainerClient.getBlobClient(imageId); + public void deleteBlob(String blobName) { + BlobClient blobClient = blobContainerClient.getBlobClient(blobName); blobClient.delete(); } diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java index 39ac4cfd..4c2062e0 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java @@ -7,14 +7,13 @@ import java.util.UUID; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @Slf4j @Service -@RequiredArgsConstructor public class CourtPhotoService { private final CourtPhotoRepository courtPhotoRepository; @@ -22,6 +21,16 @@ public class CourtPhotoService { private final AzureBlobService azureBlobService; private final AuditUserContext auditUserContext; + public CourtPhotoService(CourtPhotoRepository courtPhotoRepository, + CourtService courtService, + @Qualifier("photoAzureBlobService") AzureBlobService azureBlobService, + AuditUserContext auditUserContext) { + this.courtPhotoRepository = courtPhotoRepository; + this.courtService = courtService; + this.azureBlobService = azureBlobService; + this.auditUserContext = auditUserContext; + } + /** * Get a court photo by court id. * diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java new file mode 100644 index 00000000..b0a96441 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java @@ -0,0 +1,108 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException; +import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile; +import uk.gov.hmcts.reform.fact.data.api.utils.CsvUtil; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Qualifier; + +/** + * Service for handling CSV file operations. + */ +@Slf4j +@Service +public class CsvService { + + private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + private static final String CSV_CONTENT_TYPE = "text/csv"; + + private final CourtService courtService; + private final CourtDetailsViewService courtDetailsViewService; + private final AzureBlobService azureBlobService; + private final ObjectMapper objectMapper; + private final SlackClient slackClient; + + public CsvService(CourtService courtService, + CourtDetailsViewService courtDetailsViewService, + @Qualifier("csvAzureBlobService") AzureBlobService azureBlobService, + ObjectMapper objectMapper, + SlackClient slackClient) { + this.courtService = courtService; + this.courtDetailsViewService = courtDetailsViewService; + this.azureBlobService = azureBlobService; + this.objectMapper = objectMapper; + this.slackClient = slackClient; + } + + public void createAndUploadCsv() { + List actions = new ArrayList<>(); + try { + StringMultipartFile csvFile = createCsvFile(actions); + uploadCsvToAzureBlob(actions, csvFile); + } finally { + sendSlackSummary(actions); + } + } + + public void uploadCsvToAzureBlob(List actions, StringMultipartFile stringMultipartFile) { + try { + azureBlobService + .uploadFile(CSV_FILE_NAME, stringMultipartFile); + } catch (Exception e) { + log.error("Error while uploading CSV", e); + actions.add("Failed to upload CSV file to Azure Blob Storage. Check App insights."); + throw new AzureUploadException("Failed to upload CSV file to Azure Blob Storage", e); + } + } + + public StringMultipartFile createCsvFile(List actions) { + try { + return new StringMultipartFile( + CSV_FILE_NAME, + CSV_FILE_NAME, + CSV_CONTENT_TYPE, + new CsvUtil() + .convertJsonToCsv( + objectMapper.valueToTree( + courtService.getAllCourtDetails().stream().map( + courtDetailsViewService::prepareDetailsView) + .toList())) + ); + } catch (Exception e) { + log.error("Error while creating CSV file", e); + actions.add("Failed to create CSV file. Check App insights."); + throw new CsvCreationException("Failed to create CSV file", e); + } + } + + /** + * Sends a summary of the CSV creation and upload check to Slack. + * @param actions List of action descriptions to include in the summary + */ + private void sendSlackSummary(List actions) { + ZonedDateTime nowUk = ZonedDateTime.now(ZoneId.of("Europe/London")); + String timestamp = nowUk.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + StringBuilder sb = new StringBuilder( + String.format("*:mag: CSV creation and upload check (%s)*\n", timestamp) + ); + if (!actions.isEmpty()) { + sb.append("> ❗ Issue found:\n"); + for (String action : actions) { + sb.append("• ").append(action).append("\n"); + } + slackClient.sendSlackMessage(sb.toString()); + } + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java new file mode 100644 index 00000000..47e9ee3f --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java @@ -0,0 +1,421 @@ +package uk.gov.hmcts.reform.fact.data.api.utils; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.dataformat.csv.CsvMapper; +import tools.jackson.dataformat.csv.CsvSchema; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for converting court-related JSON data into a flat CSV format. + * + *

This class uses the Jackson CSV and JSON libraries to parse and flatten complex, nested JSON structures + * representing court details into a simplified CSV format. It includes specific logic to handle various fields + * such as court names, geolocation, types, addresses, and areas of law, transforming them into string + * representations suitable for CSV output

. + * + *

Key functionality includes: + * - Flattening nested JSON nodes (like addresses and areas of law) into single string fields. + * - Building a consistent CSV schema for output. + * - Safely handling missing or null fields to ensure robust CSV generation

. + * + *

Intended for use in services or tools that need to export structured court data from JSON APIs into a + * human-readable and spreadsheet-friendly CSV format

. + */ +public class CsvUtil { + private static final String NAME = "name"; + private static final String LAT = "lat"; + private static final String LON = "lon"; + private static final String SLUG = "slug"; + private static final String TYPES = "types"; + private static final String CCI_CODE = "cci_code"; + private static final String MAGISTRATE_CODE = "magistrate_code"; + private static final String AREAS_OF_LAW = "areas_of_law"; + private static final String AREAS_OF_LAW_PATH = "areasOfLaw"; + private static final String ADDRESSES = "addresses"; + private static final String COURT_ADDRESSES = "courtAddresses"; + private static final String COURT_TYPES = "courtTypes"; + private static final String DX_NUMBER = "dx_number"; + private static final String NOT_AVAILABLE = "N/A"; + private static final String PIPE_SEPARATOR = " | "; + private static final String NO_AREAS_OF_LAW_AVAILABLE = "No areas of law available"; + private static final String NO_ADDRESS_AVAILABLE = "No address available"; + private static final String AREAS_OF_LAW_LABEL = "Areas of Law"; + private static final String COURTS_LABEL = "Courts"; + + private final CsvMapper csvMapper; + + protected CsvUtil(CsvMapper csvMapper) { + this.csvMapper = csvMapper; + } + + public CsvUtil() { + this.csvMapper = CsvMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + } + + public String convertJsonToCsv(JsonNode jsonArrayNode) { + CsvSchema schema = buildCsvSchema(); + List> flatList = new ArrayList<>(); + + for (JsonNode node : jsonArrayNode) { + flatList.add(flattenCourtNode(node)); + } + + try { + return csvMapper.writer(schema).writeValueAsString(flatList); + } catch (JacksonException ex) { + throw new JsonConvertException("Failed to convert JSON to CSV: " + ex.getMessage()); + } + } + + private CsvSchema buildCsvSchema() { + return CsvSchema.builder() + .addColumn(NAME) + .addColumn(LAT) + .addColumn(LON) + .addColumn("number") + .addColumn(CCI_CODE) + .addColumn(MAGISTRATE_CODE) + .addColumn(SLUG) + .addColumn(TYPES) + .addColumn("open") + .addColumn(DX_NUMBER) + .addColumn(AREAS_OF_LAW) + .addColumn(ADDRESSES) + .build() + .withHeader(); + } + + public Map flattenCourtNode(JsonNode node) { + Map flatMap = new LinkedHashMap<>(); + JsonNode courtCode = getFirstArrayItem(node, "courtCodes"); + JsonNode primaryAddress = getFirstArrayItem(node, COURT_ADDRESSES); + + flatMap.put(NAME, node.path(NAME).asText()); + flatMap.put(LAT, readDecimal(node, primaryAddress, LAT)); + flatMap.put(LON, readDecimal(node, primaryAddress, LON)); + flatMap.put("number", readInteger(courtCode, "crownCourtCode", "crown_court_code")); + flatMap.put(CCI_CODE, readInteger(courtCode, "countyCourtCode", "county_court_code", CCI_CODE)); + flatMap.put(MAGISTRATE_CODE, readInteger( + courtCode, "magistrateCourtCode", "magistrate_court_code", MAGISTRATE_CODE)); + flatMap.put(SLUG, node.path(SLUG).asText()); + flatMap.put(TYPES, flattenTypes(node)); + flatMap.put("open", readBoolean(node, "open", "displayed", "openOnCath")); + flatMap.put(DX_NUMBER, flattenDxCodes(node)); + + flatMap.put(AREAS_OF_LAW, flattenAreasOfLaw(node.path("courtAreasOfLaw"), node.path(AREAS_OF_LAW))); + flatMap.put(ADDRESSES, flattenAddresses(node.path(COURT_ADDRESSES), node.path(ADDRESSES))); + + return flatMap; + } + + private String flattenAreasOfLaw(JsonNode... candidateNodes) { + JsonNode courtAreasOfLaw = getFirstArrayCandidate(candidateNodes); + if (courtAreasOfLaw == null || courtAreasOfLaw.isEmpty()) { + return NO_AREAS_OF_LAW_AVAILABLE; + } + + List areas = new ArrayList<>(); + for (JsonNode courtArea : courtAreasOfLaw) { + JsonNode areaList = courtArea.path(AREAS_OF_LAW_PATH); + if (areaList.isArray()) { + for (JsonNode area : areaList) { + String areaDetails = String.format( + "Name: %s, External Link: %s, Description: %s, Display Name: %s, Display External Link: %s", + safeText(area, NAME), + safeText(area, "externalLink", "external_link"), + safeText(area, "externalLinkDesc", "external_link_desc"), + safeText(area, "displayName", "display_name"), + safeText(area, "displayExternalLink", "display_external_link") + ); + areas.add(areaDetails); + } + } + } + + return areas.isEmpty() ? NO_AREAS_OF_LAW_AVAILABLE : String.join(PIPE_SEPARATOR, areas); + } + + private String flattenAddresses(JsonNode... candidateNodes) { + JsonNode addressesNode = getFirstArrayCandidate(candidateNodes); + if (addressesNode == null) { + return NO_ADDRESS_AVAILABLE; + } + if (!addressesNode.isArray() || addressesNode.isEmpty()) { + return NO_ADDRESS_AVAILABLE; + } + + List addresses = new ArrayList<>(); + for (JsonNode address : addressesNode) { + String addressDetails = String.format( + "Town: %s, Postcode: %s, Address: %s, Type: %s, County: %s, %s, Description: %s, EPIM ID: %s", + safeText(address, "townCity", "town"), + safeText(address, "postcode"), + flattenAddressLines(address.path("addressLines"), address.path("address_lines"), address), + safeText(address, "addressType", "type"), + safeText(address, "county"), + flattenFieldsOfLaw(address.path("fieldsOfLaw"), address.path("fields_of_law"), address), + safeText(address, "description"), + safeText(address, "epimId", "epim_id") + ); + + addresses.add(addressDetails); + } + + return String.join(PIPE_SEPARATOR, addresses); + } + + private String flattenAddressLines(JsonNode... candidateNodes) { + JsonNode lines = getFirstArrayCandidate(candidateNodes); + if ((lines == null || !lines.isArray()) + && candidateNodes.length > 0 + && candidateNodes[candidateNodes.length - 1].isObject()) { + JsonNode address = candidateNodes[candidateNodes.length - 1]; + List implicitLines = new ArrayList<>(); + addIfPresent(implicitLines, safeText(address, "addressLine1")); + addIfPresent(implicitLines, safeText(address, "addressLine2")); + if (!implicitLines.isEmpty()) { + return String.join(", ", implicitLines); + } + } + + if (lines == null || !lines.isArray() || lines.isEmpty()) { + return "No address lines"; + } + + List lineList = new ArrayList<>(); + lines.forEach(line -> lineList.add(line.asText())); + return String.join(", ", lineList); + } + + private String flattenFieldsOfLaw(JsonNode... candidateNodes) { + JsonNode fieldsOfLawNode = getFirstObjectCandidate(candidateNodes); + if (fieldsOfLawNode == null || !fieldsOfLawNode.isObject()) { + return flattenFieldsOfLawFromAddress(candidateNodes); + } + + List parts = new ArrayList<>(); + addFieldsOfLawAreas(parts, fieldsOfLawNode); + addFieldsOfLawCourts(parts, fieldsOfLawNode); + + return parts.isEmpty() ? NOT_AVAILABLE : String.join(", ", parts); + } + + private String flattenFieldsOfLawFromAddress(JsonNode... candidateNodes) { + JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; + if (addressNode != null && addressNode.isObject()) { + List parts = new ArrayList<>(); + addNamesFromArray(parts, addressNode.path(AREAS_OF_LAW_PATH), AREAS_OF_LAW_LABEL); + addNamesFromArray(parts, addressNode.path(COURT_TYPES), COURTS_LABEL); + return parts.isEmpty() ? NOT_AVAILABLE : String.join(", ", parts); + } + return NOT_AVAILABLE; + } + + private void addFieldsOfLawAreas(List parts, JsonNode fieldsOfLawNode) { + JsonNode areas = fieldsOfLawNode.path(AREAS_OF_LAW_PATH); + if (areas.isMissingNode()) { + areas = fieldsOfLawNode.path(AREAS_OF_LAW); + } + if (areas.isArray() && !areas.isEmpty()) { + List names = new ArrayList<>(); + areas.forEach(a -> names.add(a.isTextual() ? a.asText() : safeText(a, NAME))); + parts.add(AREAS_OF_LAW_LABEL + ": " + String.join(PIPE_SEPARATOR, names)); + } + } + + private void addFieldsOfLawCourts(List parts, JsonNode fieldsOfLawNode) { + JsonNode courts = fieldsOfLawNode.path("courts"); + if (courts.isMissingNode()) { + courts = fieldsOfLawNode.path(COURT_TYPES); + } + if (courts.isArray() && !courts.isEmpty()) { + List names = new ArrayList<>(); + courts.forEach(c -> names.add(c.isTextual() ? c.asText() : safeText(c, NAME))); + parts.add(COURTS_LABEL + ": " + String.join(PIPE_SEPARATOR, names)); + } + } + + private String flattenDxCodes(JsonNode node) { + JsonNode dxCodes = node.path("courtDxCodes"); + if (dxCodes.isArray() && !dxCodes.isEmpty()) { + List values = new ArrayList<>(); + for (JsonNode dxCode : dxCodes) { + String value = safeText(dxCode, "dxCode", DX_NUMBER); + if (!NOT_AVAILABLE.equals(value)) { + values.add(value); + } + } + if (!values.isEmpty()) { + return String.join(PIPE_SEPARATOR, values); + } + } + return node.path(DX_NUMBER).asText(); + } + + private String flattenTypes(JsonNode node) { + if (node.path(TYPES).isArray()) { + return stringifyArray(node.path(TYPES)); + } + + List types = new ArrayList<>(); + collectTypesFromAddresses(node.path(COURT_ADDRESSES), types); + + if (types.isEmpty()) { + collectTypesFromCounterService(node.path("courtCounterServiceOpeningHours"), types); + } + + return String.join(PIPE_SEPARATOR, types); + } + + private void collectTypesFromAddresses(JsonNode addresses, List types) { + if (addresses.isArray()) { + for (JsonNode address : addresses) { + JsonNode courtTypes = address.path(COURT_TYPES); + if (courtTypes.isArray()) { + for (JsonNode courtType : courtTypes) { + addTypeNameToList(courtType, types); + } + } + } + } + } + + private void collectTypesFromCounterService(JsonNode counterServiceOpeningHours, List types) { + if (counterServiceOpeningHours.isArray()) { + for (JsonNode openingHour : counterServiceOpeningHours) { + JsonNode courtTypes = openingHour.path(COURT_TYPES); + if (courtTypes.isArray()) { + for (JsonNode courtType : courtTypes) { + addTypeNameToList(courtType, types); + } + } + } + } + } + + private void addTypeNameToList(JsonNode courtType, List types) { + String name = courtType.isTextual() ? courtType.asText() : safeText(courtType, NAME); + if (!NOT_AVAILABLE.equals(name) && !types.contains(name)) { + types.add(name); + } + } + + private JsonNode getFirstArrayItem(JsonNode node, String field) { + JsonNode array = node.path(field); + if (array.isArray() && !array.isEmpty()) { + return array.get(0); + } + return null; + } + + private JsonNode getFirstArrayCandidate(JsonNode... nodes) { + for (JsonNode candidate : nodes) { + if (candidate != null && candidate.isArray()) { + return candidate; + } + } + return null; + } + + private JsonNode getFirstObjectCandidate(JsonNode... nodes) { + for (JsonNode candidate : nodes) { + if (candidate != null && candidate.isObject()) { + return candidate; + } + } + return null; + } + + private Double readDecimal(JsonNode primary, JsonNode secondary, String field) { + JsonNode value = primary.path(field); + if (value.isNumber()) { + return value.asDouble(); + } + if (secondary != null) { + value = secondary.path(field); + if (value.isNumber()) { + return value.asDouble(); + } + } + return null; + } + + private Integer readInteger(JsonNode node, String... fields) { + if (node == null) { + return null; + } + for (String field : fields) { + JsonNode value = node.path(field); + if (value.isNumber()) { + return value.asInt(); + } + } + return null; + } + + private Boolean readBoolean(JsonNode node, String... fields) { + for (String field : fields) { + JsonNode value = node.path(field); + if (value.isBoolean()) { + return value.asBoolean(); + } + } + return false; + } + + private String stringifyArray(JsonNode arrayNode) { + if (arrayNode == null || !arrayNode.isArray()) { + return ""; + } + List items = new ArrayList<>(); + arrayNode.forEach(n -> items.add(n.asText())); + return String.join(PIPE_SEPARATOR, items); + } + + private String safeText(JsonNode node, String... fieldNames) { + if (node == null) { + return NOT_AVAILABLE; + } + for (String fieldName : fieldNames) { + JsonNode field = node.path(fieldName); + if (!field.isMissingNode() && !field.isNull()) { + return field.asText(); + } + } + return NOT_AVAILABLE; + } + + private void addIfPresent(List values, String value) { + if (value != null && !value.isBlank() && !NOT_AVAILABLE.equals(value)) { + values.add(value); + } + } + + private void addNamesFromArray(List parts, JsonNode arrayNode, String label) { + if (arrayNode.isArray() && !arrayNode.isEmpty()) { + List names = new ArrayList<>(); + arrayNode.forEach(item -> { + if (item.isTextual()) { + names.add(item.asText()); + } else { + String name = safeText(item, NAME); + if (!NOT_AVAILABLE.equals(name)) { + names.add(name); + } + } + }); + if (!names.isEmpty()) { + parts.add(label + ": " + String.join(PIPE_SEPARATOR, names)); + } + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e8d135ee..7c801b01 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -69,7 +69,8 @@ spring: storage: account-name: ${AZURE_STORAGE_ACCOUNT_NAME} blob: - container-name: photos + photo-container-name: photos + csv-container-name: csv connection-string: ${AZURE_STORAGE_CONNECTION_STRING:} # required for security flows active-directory: diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTest.java index 38065697..155bb085 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTest.java @@ -30,8 +30,19 @@ void setUp() { } @Test - void testBlobContainerClientBean() { - BlobContainerClient client = azureBlobConfiguration.blobContainerClient( + void testPhotoBlobContainerClientBean() { + BlobContainerClient client = azureBlobConfiguration.photoBlobContainerClient( + mockBlobServiceClient, containerName + ); + + assertEquals(mockBlobContainerClient, client); + verify(mockBlobServiceClient, times(1)) + .getBlobContainerClient(containerName); + } + + @Test + void testCsvBlobContainerClientBean() { + BlobContainerClient client = azureBlobConfiguration.csvBlobContainerClient( mockBlobServiceClient, containerName ); diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java new file mode 100644 index 00000000..5bc90d2b --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java @@ -0,0 +1,27 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +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 uk.gov.hmcts.reform.fact.data.api.services.CsvService; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CsvControllerTest { + + @Mock + private CsvService csvService; + + @InjectMocks + private CsvController csvController; + + @Test + void createAndUploadCsvShouldDelegateToService() { + csvController.createAndUploadCsv(); + + verify(csvService).createAndUploadCsv(); + } +} diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandlerTest.java index 58d74474..6357876c 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandlerTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandlerTest.java @@ -15,14 +15,15 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CourtResourceNotFoundException; - import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.DuplicatedListItemException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidAreaOfLawException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidDateRangeException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidFileException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidParameterCombinationException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; + import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidUUID; import java.lang.annotation.Annotation; @@ -327,6 +328,18 @@ void testHandleInvalidAreaOfLawTypeException() { assertThat(response.getTimestamp()).isNotNull(); } + @Test + void testHandleJsonConvertExceptionException() { + JsonConvertException ex = + new JsonConvertException("Json Convert Exception Message"); + + ExceptionResponse response = handler.handle(ex); + + assertThat(response).isNotNull(); + assertThat(response.getMessage()).contains("Json Convert Exception Message"); + assertThat(response.getTimestamp()).isNotNull(); + } + private ConstraintViolation createConstraintViolation( Annotation annotation, Object invalidValue, diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CustomExceptionsTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CustomExceptionsTest.java index 528b7b9e..150f0efe 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CustomExceptionsTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CustomExceptionsTest.java @@ -47,4 +47,16 @@ void testCreationOfInvalidAreaOfLawTypeException() { InvalidAreaOfLawException exception = new InvalidAreaOfLawException(TEST_MESSAGE); assertEquals(TEST_MESSAGE, exception.getMessage(), ASSERTION_MESSAGE); } + + @Test + void testCreationOfCsvCreationException() { + CsvCreationException exception = new CsvCreationException(TEST_MESSAGE); + assertEquals(TEST_MESSAGE, exception.getMessage(), ASSERTION_MESSAGE); + } + + @Test + void testCreationOfAzureUploadException() { + AzureUploadException exception = new AzureUploadException(TEST_MESSAGE); + assertEquals(TEST_MESSAGE, exception.getMessage(), ASSERTION_MESSAGE); + } } diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFileTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFileTest.java new file mode 100644 index 00000000..cb0483d1 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFileTest.java @@ -0,0 +1,71 @@ +package uk.gov.hmcts.reform.fact.data.api.models; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class StringMultipartFileTest { + + private static final String NAME = "testFile"; + private static final String ORIGINAL_FILE_NAME = "test.csv"; + private static final String CONTENT_TYPE = "text/csv"; + private static final String CONTENT = "This is test content."; + + @Test + void shouldReturnCorrectName() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.getName()).isEqualTo(NAME); + } + + @Test + void shouldReturnCorrectOriginalFileName() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.getOriginalFilename()).isEqualTo(ORIGINAL_FILE_NAME); + } + + @Test + void shouldReturnCorrectContentType() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.getContentType()).isEqualTo(CONTENT_TYPE); + } + + @Test + void shouldNotBeEmptyWhenContentIsProvided() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.isEmpty()).isFalse(); + } + + @Test + void shouldBeEmptyWhenContentIsEmpty() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, ""); + assertThat(file.isEmpty()).isTrue(); + } + + @Test + void shouldReturnCorrectSize() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.getSize()).isEqualTo(CONTENT.getBytes().length); + } + + @Test + void shouldReturnCorrectBytes() { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + assertThat(file.getBytes()).isEqualTo(CONTENT.getBytes()); + } + + @Test + void shouldReturnCorrectInputStreamContent() throws IOException { + StringMultipartFile file = new StringMultipartFile(NAME, ORIGINAL_FILE_NAME, CONTENT_TYPE, CONTENT); + InputStream inputStream = file.getInputStream(); + + byte[] buffer = new byte[CONTENT.length()]; + int bytesRead = inputStream.read(buffer); + + assertThat(bytesRead).isEqualTo(CONTENT.length()); + assertThat(new String(buffer)).isEqualTo(CONTENT); + } +} + diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java index 1f6a0034..8ea4ad9e 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java @@ -2,7 +2,9 @@ import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; import com.azure.storage.blob.models.BlobHttpHeaders; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -33,6 +35,9 @@ class AzureBlobServiceTest { @Mock private BlobContainerClient blobContainerClient; + @Mock + private BlobServiceClient blobServiceClient; + @Mock private BlobClient blobClient; @@ -42,12 +47,16 @@ class AzureBlobServiceTest { @InjectMocks private AzureBlobService azureBlobService; + @BeforeEach + void setUp() { + when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); + } + @Test void uploadFileShouldUploadAndReturnUrl() throws IOException { byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); InputStream inputStream = new ByteArrayInputStream(fileBytes); - when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); when(multipartFile.getInputStream()).thenReturn(inputStream); when(multipartFile.getSize()).thenReturn((long) fileBytes.length); when(multipartFile.getContentType()).thenReturn(CONTENT_TYPE); @@ -66,7 +75,6 @@ void uploadFileShouldUploadAndReturnUrl() throws IOException { @Test void uploadFileShouldThrowAzureUploadExceptionWhenReadingFails() throws IOException { - when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); when(multipartFile.getInputStream()).thenThrow(new IOException("Read failure")); assertThrows(AzureUploadException.class, () -> @@ -79,10 +87,53 @@ void uploadFileShouldThrowAzureUploadExceptionWhenReadingFails() throws IOExcept @Test void deleteBlobShouldDeleteBlobById() { - when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); - azureBlobService.deleteBlob(IMAGE_ID); verify(blobClient).delete(); } + + @Test + void uploadFileWithContainerShouldUploadToProvidedContainer() throws IOException { + byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); + InputStream inputStream = new ByteArrayInputStream(fileBytes); + + when(multipartFile.getInputStream()).thenReturn(inputStream); + when(multipartFile.getSize()).thenReturn((long) fileBytes.length); + when(multipartFile.getContentType()).thenReturn(CONTENT_TYPE); + when(blobClient.getBlobUrl()).thenReturn(BLOB_URL); + + String result = azureBlobService.uploadFile(IMAGE_ID, multipartFile); + + assertThat(result).isEqualTo(BLOB_URL); + verify(blobClient).upload(inputStream, fileBytes.length, true); + verify(blobClient).setHttpHeaders(org.mockito.ArgumentMatchers.any(BlobHttpHeaders.class)); + } + + @Test + void uploadFileWithContainerShouldCreateContainerWhenMissing() throws IOException { + byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); + InputStream inputStream = new ByteArrayInputStream(fileBytes); + + when(multipartFile.getInputStream()).thenReturn(inputStream); + when(multipartFile.getSize()).thenReturn((long) fileBytes.length); + when(multipartFile.getContentType()).thenReturn(CONTENT_TYPE); + when(blobClient.getBlobUrl()).thenReturn(BLOB_URL); + + String result = azureBlobService.uploadFile(IMAGE_ID, multipartFile); + + assertThat(result).isEqualTo(BLOB_URL); + verify(blobClient).upload(inputStream, fileBytes.length, true); + verify(blobClient).setHttpHeaders(org.mockito.ArgumentMatchers.any(BlobHttpHeaders.class)); + } + + @Test + void uploadFileWithContainerShouldThrowAzureUploadExceptionWhenReadingFails() throws IOException { + when(multipartFile.getInputStream()).thenThrow(new IOException("Read failure")); + + assertThrows(AzureUploadException.class, () -> + azureBlobService.uploadFile(IMAGE_ID, multipartFile) + ); + + verify(blobClient, never()).setHttpHeaders(org.mockito.ArgumentMatchers.any(BlobHttpHeaders.class)); + } } diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java new file mode 100644 index 00000000..ef7fadec --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java @@ -0,0 +1,118 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException; +import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CsvServiceTest { + + private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + + @Mock + private CourtService courtService; + + @Mock + private CourtDetailsViewService courtDetailsViewService; + + @Mock + private AzureBlobService azureBlobService; + + @Mock + private SlackClient slackClient; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + @Test + void uploadCsvToAzureBlobShouldUseConfiguredContainerName() { + CsvService csvService = buildService(); + List actions = new ArrayList<>(); + StringMultipartFile csvFile = new StringMultipartFile(CSV_FILE_NAME, CSV_FILE_NAME, "text/csv", "h1\nvalue"); + + csvService.uploadCsvToAzureBlob(actions, csvFile); + + verify(azureBlobService).uploadFile(CSV_FILE_NAME, csvFile); + assertThat(actions).isEmpty(); + } + + @Test + void uploadCsvToAzureBlobShouldAppendActionAndThrowWhenUploadFails() { + CsvService csvService = buildService(); + List actions = new ArrayList<>(); + StringMultipartFile csvFile = new StringMultipartFile(CSV_FILE_NAME, CSV_FILE_NAME, "text/csv", "h1\nvalue"); + + doThrow(new RuntimeException("azure failure")) + .when(azureBlobService) + .uploadFile(CSV_FILE_NAME, csvFile); + + AzureUploadException exception = assertThrows(AzureUploadException.class, () -> + csvService.uploadCsvToAzureBlob(actions, csvFile) + ); + + assertThat(exception.getMessage()).isEqualTo("Failed to upload CSV file to Azure Blob Storage"); + assertThat(actions).containsExactly("Failed to upload CSV file to Azure Blob Storage. Check App insights."); + } + + @Test + void createAndUploadCsvShouldCreateAndUploadWithoutSlackMessageOnSuccess() { + CsvService csvService = buildService(); + when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); + + csvService.createAndUploadCsv(); + + verify(azureBlobService) + .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + verify(slackClient, never()).sendSlackMessage(any()); + } + + @Test + void createAndUploadCsvShouldSendSlackMessageAndThrowWhenCsvCreationFails() { + CsvService csvService = buildService(); + when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("court failure")); + + CsvCreationException exception = assertThrows(CsvCreationException.class, csvService::createAndUploadCsv); + + assertThat(exception.getMessage()).isEqualTo("Failed to create CSV file"); + verify(slackClient).sendSlackMessage(contains("Failed to create CSV file. Check App insights.")); + } + + @Test + void createAndUploadCsvShouldSendSlackMessageAndThrowWhenUploadFails() { + CsvService csvService = buildService(); + when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); + doThrow(new RuntimeException("azure failure")) + .when(azureBlobService) + .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + + AzureUploadException exception = assertThrows(AzureUploadException.class, csvService::createAndUploadCsv); + + assertThat(exception.getMessage()).isEqualTo("Failed to upload CSV file to Azure Blob Storage"); + verify(slackClient) + .sendSlackMessage(contains("Failed to upload CSV file to Azure Blob Storage. Check App insights.")); + } + + private CsvService buildService() { + return new CsvService( + courtService, courtDetailsViewService, azureBlobService, objectMapper, slackClient); + } +} diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java new file mode 100644 index 00000000..05771e09 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java @@ -0,0 +1,314 @@ +package uk.gov.hmcts.reform.fact.data.api.utils; + +import tools.jackson.core.JacksonException; +import tools.jackson.dataformat.csv.CsvMapper; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CsvUtilTest { + + private final ObjectMapper mapper = JsonMapper.builder().build(); + private final CsvUtil csvUtil = new CsvUtil(); + + @Test + void shouldFlattenEnrichedAreasOfLaw() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode courtAreasOfLaw = root.putArray("courtAreasOfLaw"); + ObjectNode courtArea = courtAreasOfLaw.addObject(); + ArrayNode areaList = courtArea.putArray("areasOfLaw"); + ObjectNode area = areaList.addObject(); + area.put("name", "Family"); + area.put("externalLink", "http://family.com"); + area.put("displayName", "Family Law"); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("areas_of_law").toString()) + .contains("Name: Family") + .contains("External Link: http://family.com") + .contains("Display Name: Family Law"); + } + + @Test + void shouldFlattenEnrichedAddressesAndFieldsOfLaw() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + address.put("addressLine1", "10 High St"); + address.put("townCity", "London"); + address.put("postcode", "SW1 1AA"); + + ArrayNode addressAreas = address.putArray("areasOfLaw"); + ObjectNode area = addressAreas.addObject(); + area.put("name", "Crime"); + + ArrayNode addressTypes = address.putArray("courtTypes"); + ObjectNode type = addressTypes.addObject(); + type.put("name", "Crown Court"); + + Map result = csvUtil.flattenCourtNode(root); + + String addressesStr = result.get("addresses").toString(); + assertThat(addressesStr) + .contains("Address: 10 High St") + .contains("Town: London") + .contains("Postcode: SW1 1AA") + .contains("Areas of Law: Crime") + .contains("Courts: Crown Court"); + + // Verify types flattening as well + assertThat(result.get("types")).hasToString("Crown Court"); + } + + @Test + void shouldFlattenTypesFromCounterServiceIfAddressesEmpty() { + ObjectNode root = mapper.createObjectNode(); + root.putArray("courtAddresses"); // Empty + + ArrayNode counterService = root.putArray("courtCounterServiceOpeningHours"); + ObjectNode service = counterService.addObject(); + ArrayNode serviceTypes = service.putArray("courtTypes"); + ObjectNode type = serviceTypes.addObject(); + type.put("name", "Magistrates' Court"); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("types")).hasToString("Magistrates' Court"); + } + + @Test + void shouldHandleOpenOnCath() { + ObjectNode root = mapper.createObjectNode(); + root.put("openOnCath", true); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result).containsEntry("open", true); + } + + @Test + void shouldReturnNotAvailableIfNoNodesProvided() { + Map result = csvUtil.flattenCourtNode(mapper.createObjectNode()); + assertThat(result.get("addresses")).hasToString("No address available"); + } + + @Test + void shouldFlattenTypesFromAddresses() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + ArrayNode courtTypes = address.putArray("courtTypes"); + courtTypes.add("County Court"); + ObjectNode type2 = courtTypes.addObject(); + type2.put("name", "Tribunal"); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("types")).hasToString("County Court | Tribunal"); + } + + @Test + void shouldFlattenTypesFromCounterService() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode counterService = root.putArray("courtCounterServiceOpeningHours"); + ObjectNode service = counterService.addObject(); + ArrayNode courtTypes = service.putArray("courtTypes"); + courtTypes.add("Magistrates' Court"); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("types")).hasToString("Magistrates' Court"); + } + + @Test + void shouldAvoidDuplicateTypesFromCounterService() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode counterService = root.putArray("courtCounterServiceOpeningHours"); + + ObjectNode service1 = counterService.addObject(); + service1.putArray("courtTypes").add("Magistrates' Court"); + + ObjectNode service2 = counterService.addObject(); + service2.putArray("courtTypes").add("Magistrates' Court"); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("types")).hasToString("Magistrates' Court"); + } + + @Test + void shouldFlattenFieldsOfLawWithAreasAndCourts() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + ObjectNode fieldsOfLaw = address.putObject("fieldsOfLaw"); + + ArrayNode areas = fieldsOfLaw.putArray("areasOfLaw"); + ObjectNode a1 = areas.addObject(); + a1.put("name", "Family"); + + ArrayNode courts = fieldsOfLaw.putArray("courts"); + ObjectNode c1 = courts.addObject(); + c1.put("name", "High Court"); + + Map result = csvUtil.flattenCourtNode(root); + String addressesStr = result.get("addresses").toString(); + assertThat(addressesStr) + .contains("Areas of Law: Family") + .contains("Courts: High Court"); + } + + @Test + void shouldFlattenFieldsOfLawWithTextualAreasAndCourts() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + ObjectNode fieldsOfLaw = address.putObject("fieldsOfLaw"); + + ArrayNode areas = fieldsOfLaw.putArray("areasOfLaw"); + areas.add("Family"); + + ArrayNode courts = fieldsOfLaw.putArray("courts"); + courts.add("High Court"); + + Map result = csvUtil.flattenCourtNode(root); + String addressesStr = result.get("addresses").toString(); + assertThat(addressesStr) + .contains("Areas of Law: Family") + .contains("Courts: High Court"); + } + + @Test + void shouldFallbackToAddressNodeWhenFieldsOfLawNodeMissing() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + + ArrayNode addressAreas = address.putArray("areasOfLaw"); + addressAreas.add("Crime"); + + ArrayNode addressTypes = address.putArray("courtTypes"); + addressTypes.add("Crown Court"); + + Map result = csvUtil.flattenCourtNode(root); + String addressesStr = result.get("addresses").toString(); + assertThat(addressesStr) + .contains("Areas of Law: Crime") + .contains("Courts: Crown Court"); + } + + @Test + void shouldConvertJsonToCsv() { + ArrayNode root = mapper.createArrayNode(); + ObjectNode court = root.addObject(); + court.put("name", "Test Court"); + court.put("slug", "test-court"); + + String csv = csvUtil.convertJsonToCsv(root); + + assertThat(csv) + .contains("name,lat,lon,number,cci_code,magistrate_code,slug,types,open,dx_number,areas_of_law,addresses"); + assertThat(csv) + .contains("Test Court") + .contains("test-court"); + } + + @Test + void shouldThrowExceptionOnInvalidJson() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + address.put("addressLine1", "Line 1"); + address.put("addressLine2", "Line 2"); + + Map result = csvUtil.flattenCourtNode(root); + assertThat(result.get("addresses").toString()).contains("Address: Line 1, Line 2"); + + ArrayNode lines = address.putArray("addressLines"); + lines.add("Line A"); + lines.add("Line B"); + + result = csvUtil.flattenCourtNode(root); + assertThat(result.get("addresses").toString()).contains("Address: Line A, Line B"); + } + + @Test + void shouldHandleDxCodes() { + ObjectNode root = mapper.createObjectNode(); + root.put("dx_number", "DX 123"); + + Map result = csvUtil.flattenCourtNode(root); + assertThat(result).containsEntry("dx_number", "DX 123"); + + ArrayNode dxCodes = root.putArray("courtDxCodes"); + ObjectNode dx1 = dxCodes.addObject(); + dx1.put("dxCode", "DX 456"); + + result = csvUtil.flattenCourtNode(root); + assertThat(result).containsEntry("dx_number", "DX 456"); + } + + @Test + void shouldReadNumbersAndBooleansCorrectly() { + ObjectNode root = mapper.createObjectNode(); + root.put("open", true); + + ArrayNode courtCodes = root.putArray("courtCodes"); + ObjectNode code = courtCodes.addObject(); + code.put("crownCourtCode", 123); + code.put("countyCourtCode", 456); + code.put("magistrateCourtCode", 789); + + ArrayNode addresses = root.putArray("courtAddresses"); + ObjectNode address = addresses.addObject(); + address.put("lat", 51.5); + address.put("lon", -0.1); + + Map result = csvUtil.flattenCourtNode(root); + assertThat(result).containsEntry("open",true); + assertThat(result).containsEntry("number", 123); + assertThat(result).containsEntry("cci_code", 456); + assertThat(result).containsEntry("magistrate_code", 789); + assertThat(result).containsEntry("lat", 51.5); + assertThat(result).containsEntry("lon", -0.1); + } + + @Test + void shouldHandleNullsInReadMethods() { + ObjectNode root = mapper.createObjectNode(); + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("lat")).isNull(); + assertThat(result.get("number")).isNull(); + assertThat(result).containsEntry("open", false); + } + + @Test + void shouldThrowJsonConvertExceptionWhenCsvWritingFails() throws Exception { + CsvMapper mockCsvMapper = mock(CsvMapper.class); + CsvUtil utilWithMock = new CsvUtil(mockCsvMapper); + + when(mockCsvMapper.writer(org.mockito.ArgumentMatchers.any( + tools.jackson.dataformat.csv.CsvSchema.class))) + .thenThrow(new JacksonException("Mock failure") {}); + + ArrayNode root = mapper.createArrayNode(); + root.addObject(); + + assertThatThrownBy(() -> utilWithMock.convertJsonToCsv(root)) + .isInstanceOf(JsonConvertException.class) + .hasMessageContaining("Failed to convert JSON to CSV: Mock failure"); + } +}