From 0a2bbfe651339b00dbc3f07676eb99284dc94f88 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 6 May 2026 15:48:46 +0100 Subject: [PATCH 01/18] Initial setup for csv creation and upload --- .../data/api/controllers/CsvController.java | 28 +++ .../errorhandling/GlobalExceptionHandler.java | 9 + .../exceptions/JsonConvertException.java | 7 + .../data/api/models/StringMultipartFile.java | 61 ++++++ .../data/api/services/AzureBlobService.java | 22 ++ .../fact/data/api/services/CsvService.java | 34 ++++ .../fact/data/api/utils/CsvGenerator.java | 45 +++++ .../reform/fact/data/api/utils/CsvUtil.java | 190 ++++++++++++++++++ 8 files changed, 396 insertions(+) create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java 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..5aa1b19e --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java @@ -0,0 +1,28 @@ +package uk.gov.hmcts.reform.fact.data.api.controllers; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +import java.util.List; + +@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; + } + + @GetMapping("/files") + public ResponseEntity> getCsvFiles() { + return ResponseEntity.ok(csvService.getCsvFiles()); + } +} 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..15a2ace3 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 @@ -2,12 +2,14 @@ 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.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 +208,13 @@ 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()); + } + 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/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..68ed38a9 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java @@ -0,0 +1,61 @@ +package uk.gov.hmcts.reform.fact.data.api.models; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class StringMultipartFile implements MultipartFile { + + private final String name; + private final String originalFileName; + private final String contentType; + private final byte[] content; + + public StringMultipartFile(String name, String originalFileName, String contentType, String content) { + this.name = name; + this.originalFileName = originalFileName; + this.contentType = contentType; + this.content = content.getBytes(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFileName; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(java.io.File dest) throws IllegalStateException { + // Optionally, you can implement the logic to transfer the content to a file here. + } +} 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..7cc0c221 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,9 +3,11 @@ import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.models.BlobHttpHeaders; +import com.fasterxml.jackson.databind.JsonNode; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; +import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile; import java.io.IOException; @@ -53,4 +55,24 @@ public void deleteBlob(String imageId) { blobClient.delete(); } + + + public void createCsvFileAndUpload(String containerName, String blobName, JsonNode jsonNodeData) { + try { + uploadFile(blobName, + createCsvFile(blobName, new CsvUtil().convertJsonToCsv(jsonNodeData)) + ); + } catch (IOException ex) { + throw new AzureUploadException(ex.getMessage()); + } + } + + public StringMultipartFile createCsvFile(String blobName, String csvString) { + return new StringMultipartFile( + blobName, + blobName, + "text/csv", + csvString + ); + } } 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..2ed664e9 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java @@ -0,0 +1,34 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; + +/** + * Service for handling CSV file operations. + */ +@Service +public class CsvService { + + private final CourtService courtService; + private final ObjectMapper objectMapper; + + public CsvService(CourtService courtService, ObjectMapper objectMapper) { + this.courtService = courtService; + this.objectMapper = objectMapper; + } + + public String[] getCsvFiles() { + return new String[] {"courts.csv"}; + } + + public JsonNode convertCourtDataToJson() { + try { + return objectMapper.valueToTree(courtService.getAllCourtDetails()); + } catch (IllegalArgumentException ex) { + throw new JsonConvertException("Error converting court data to JSON", ex); + } + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java new file mode 100644 index 00000000..a4f4c54c --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java @@ -0,0 +1,45 @@ +package uk.gov.hmcts.reform.fact.data.api.runners; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; +import uk.gov.hmcts.reform.fact.data.api.services.CourtService; + +@Component +@Slf4j +public class CsvGenerator implements CommandLineRunner { + + private final AzureBlobService azureService; + private final CourtService courtService; + + public CsvGenerator(@Autowired AzureBlobService azureService, + @Autowired CourtService courtService) { + this.azureService = azureService; + this.courtService = courtService; + } + + /** + * Executes automatically when the Spring Boot application starts. + * This method is invoked as part of the application's startup process due to + * the implementation of the {@link CommandLineRunner} interface. At present, it triggers + * the generation of court and tribunal data in CSV format and uploads it to + * Azure Blob Storage. The method logs the start and completion of this process. + * + * @param args Optional command-line arguments passed during application startup. + */ + @Override + public void run(String... args) { + log.info("Running CSV generation"); + createCsvAndUpload(); + log.info("Finished running CSV generation"); + System.exit(0); + } + + public void createCsvAndUpload() { + JsonNode courtData = courtService.getCourtData(); + azureService.createCsvFileAndUpload("csv", "courts-and-tribunals-data.csv", courtData); + } +} 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..9c8a0ef8 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java @@ -0,0 +1,190 @@ +package uk.gov.hmcts.reform.fact.data.api.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.fasterxml.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 final CsvMapper csvMapper; + + protected CsvUtil(CsvMapper csvMapper) { + this.csvMapper = csvMapper; + } + + public CsvUtil() { + this.csvMapper = new CsvMapper(); + this.csvMapper.enable(SerializationFeature.INDENT_OUTPUT); + } + + 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 (RuntimeException | JsonProcessingException 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(); + } + + private Map flattenCourtNode(JsonNode node) { + Map flatMap = new LinkedHashMap<>(); + + flatMap.put("name", node.path("name").asText()); + flatMap.put("lat", node.path("lat").asDouble()); + flatMap.put("lon", node.path("lon").asDouble()); + flatMap.put("number", node.path("number").asInt()); + flatMap.put("cci_code", node.path("cci_code").asInt()); + flatMap.put("magistrate_code", node.path("magistrate_code").asInt()); + flatMap.put("slug", node.path("slug").asText()); + flatMap.put("types", stringifyArray(node.path("types"))); + flatMap.put("open", node.path("displayed").asBoolean()); + flatMap.put("dx_number", node.path("dx_number").asText()); + + flatMap.put("areas_of_law", flattenAreasOfLaw(node.path("areas_of_law"))); + flatMap.put("addresses", flattenAddresses(node.path("addresses"))); + + return flatMap; + } + + private String flattenAreasOfLaw(JsonNode areasOfLawNode) { + if (!areasOfLawNode.isArray() || areasOfLawNode.isEmpty()) { + return "No areas of law available"; + } + + List areas = new ArrayList<>(); + for (JsonNode area : areasOfLawNode) { + String areaDetails = String.format( + "Name: %s, External Link: %s, Description: %s, Display Name: %s, Display External Link: %s", + safeText(area, "name"), + safeText(area, "external_link"), + safeText(area, "external_link_desc"), + safeText(area, "display_name"), + safeText(area, "display_external_link") + ); + areas.add(areaDetails); + } + + return String.join(" | ", areas); + } + + private String flattenAddresses(JsonNode addressesNode) { + 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, "town"), + safeText(address, "postcode"), + flattenAddressLines(address.path("address_lines")), + safeText(address, "type"), + safeText(address, "county"), + flattenFieldsOfLaw(address.path("fields_of_law")), + safeText(address, "description"), + safeText(address, "epim_id") + ); + + addresses.add(addressDetails); + } + + return String.join(" | ", addresses); + } + + private String flattenAddressLines(JsonNode lines) { + if (!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 fieldsOfLawNode) { + if (!fieldsOfLawNode.isObject()) { + return "N/A"; + } + + List parts = new ArrayList<>(); + + JsonNode areas = fieldsOfLawNode.path("areas_of_law"); + if (areas.isArray() && !areas.isEmpty()) { + List names = new ArrayList<>(); + areas.forEach(a -> names.add(a.asText())); + parts.add("Areas of Law: " + String.join(" | ", names)); + } + + JsonNode courts = fieldsOfLawNode.path("courts"); + if (courts.isArray() && !courts.isEmpty()) { + List names = new ArrayList<>(); + courts.forEach(c -> names.add(c.asText())); + parts.add("Courts: " + String.join(" | ", names)); + } + + return String.join(", ", parts); + } + + 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(" | ", items); + } + + private String safeText(JsonNode node, String fieldName) { + JsonNode field = node.path(fieldName); + return field.isMissingNode() || field.isNull() ? "N/A" : field.asText(); + } +} From 840b3b09334a1a95b1144d1a6cf52ac086622a2f Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 6 May 2026 16:58:30 +0100 Subject: [PATCH 02/18] Update endpoint --- build.gradle | 1 + .../fact/data/api/controllers/CsvController.java | 11 ++++------- .../fact/data/api/services/AzureBlobService.java | 11 ++++------- .../reform/fact/data/api/services/CsvService.java | 15 ++++++++++++--- .../reform/fact/data/api/utils/CsvGenerator.java | 10 +++++----- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/build.gradle b/build.gradle index 181f5a7c..adbc0f25 100644 --- a/build.gradle +++ b/build.gradle @@ -198,6 +198,7 @@ dependencies { implementation group: 'io.hypersistence', name: 'hypersistence-utils-hibernate-63', version: '3.15.2' implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-hibernate6', version: '2.21.3' + implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-csv', version: '2.21.3' implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0' 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 index 5aa1b19e..877ce399 100644 --- 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 @@ -1,13 +1,10 @@ package uk.gov.hmcts.reform.fact.data.api.controllers; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; +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; -import java.util.List; - @SecuredFactRestController( name = "CSV", description = "Operations related to CSV file handling" @@ -21,8 +18,8 @@ public CsvController(CsvService csvService) { this.csvService = csvService; } - @GetMapping("/files") - public ResponseEntity> getCsvFiles() { - return ResponseEntity.ok(csvService.getCsvFiles()); + @PostMapping("/files") + public void createCsvFiles() { + csvService.createCsvFiles(); } } 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 7cc0c221..c0846fac 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 @@ -8,6 +8,7 @@ import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile; +import uk.gov.hmcts.reform.fact.data.api.utils.CsvUtil; import java.io.IOException; @@ -58,13 +59,9 @@ public void deleteBlob(String imageId) { public void createCsvFileAndUpload(String containerName, String blobName, JsonNode jsonNodeData) { - try { - uploadFile(blobName, - createCsvFile(blobName, new CsvUtil().convertJsonToCsv(jsonNodeData)) - ); - } catch (IOException ex) { - throw new AzureUploadException(ex.getMessage()); - } + uploadFile(blobName, + createCsvFile(blobName, new CsvUtil().convertJsonToCsv(jsonNodeData)) + ); } public StringMultipartFile createCsvFile(String blobName, String csvString) { 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 index 2ed664e9..1374e60f 100644 --- 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 @@ -12,16 +12,25 @@ @Service public class CsvService { + private static final String CSV_CONTAINER_NAME = "csv"; + private static final String COURT_DATA_CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + private final CourtService courtService; + private final AzureBlobService azureBlobService; private final ObjectMapper objectMapper; - public CsvService(CourtService courtService, ObjectMapper objectMapper) { + public CsvService(CourtService courtService, + AzureBlobService azureBlobService, + ObjectMapper objectMapper) { this.courtService = courtService; + this.azureBlobService = azureBlobService; this.objectMapper = objectMapper; } - public String[] getCsvFiles() { - return new String[] {"courts.csv"}; + public JsonNode createCsvFiles() { + JsonNode courtData = convertCourtDataToJson(); + azureBlobService.createCsvFileAndUpload(CSV_CONTAINER_NAME, COURT_DATA_CSV_FILE_NAME, courtData); + return courtData; } public JsonNode convertCourtDataToJson() { diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java index a4f4c54c..bdcf0753 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java @@ -6,19 +6,19 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; -import uk.gov.hmcts.reform.fact.data.api.services.CourtService; +import uk.gov.hmcts.reform.fact.data.api.services.CsvService; @Component @Slf4j public class CsvGenerator implements CommandLineRunner { private final AzureBlobService azureService; - private final CourtService courtService; + private final CsvService csvService; public CsvGenerator(@Autowired AzureBlobService azureService, - @Autowired CourtService courtService) { + @Autowired CsvService csvService) { this.azureService = azureService; - this.courtService = courtService; + this.csvService = csvService; } /** @@ -39,7 +39,7 @@ public void run(String... args) { } public void createCsvAndUpload() { - JsonNode courtData = courtService.getCourtData(); + JsonNode courtData = csvService.convertCourtDataToJson(); azureService.createCsvFileAndUpload("csv", "courts-and-tribunals-data.csv", courtData); } } From a9cf2a23e90d1bf20eccc33e162fc00bffd517f0 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Thu, 7 May 2026 17:32:21 +0100 Subject: [PATCH 03/18] Update --- .../CsvControllerFunctionalTest.java | 35 +++ .../api/controllers/CsvControllerTest.java | 53 ++++ .../data/api/controllers/CsvController.java | 17 +- .../data/api/services/AzureBlobService.java | 47 ++-- .../fact/data/api/services/CsvService.java | 81 ++++++- .../fact/data/api/utils/CsvGenerator.java | 45 ---- .../reform/fact/data/api/utils/CsvUtil.java | 228 ++++++++++++++++-- .../api/controllers/CsvControllerTest.java | 27 +++ .../api/services/AzureBlobServiceTest.java | 56 +++++ .../data/api/services/CsvServiceTest.java | 85 +++++++ 10 files changed, 570 insertions(+), 104 deletions(-) create mode 100644 src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java create mode 100644 src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java delete mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java 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/controllers/CsvControllerTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java new file mode 100644 index 00000000..f82af803 --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java @@ -0,0 +1,53 @@ +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.test.autoconfigure.web.servlet.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.services.CsvService; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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 + private CsvService csvService; + + @Test + @DisplayName("POST /csv/ returns 200 when CSV is created and uploaded successfully") + void createAndUploadCsvReturns200() throws Exception { + doNothing().when(csvService).createAndUploadCsv(); + + mvc.perform(post("/csv/")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /csv/ propagates exception when CSV creation/upload fails") + void createAndUploadCsvThrowsException() { + doThrow(new RuntimeException("Failed to create or upload CSV file")) + .when(csvService) + .createAndUploadCsv(); + + assertThatThrownBy(() -> mvc.perform(post("/csv/"))) + .hasRootCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to create or upload CSV file"); + } +} 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 index 877ce399..b24f7ac6 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -18,8 +21,16 @@ public CsvController(CsvService csvService) { this.csvService = csvService; } - @PostMapping("/files") - public void createCsvFiles() { - csvService.createCsvFiles(); + @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 or upload CSV file") + }) + public void createAndUploadCsv() { + csvService.createAndUploadCsv(); } } 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 c0846fac..56fc2bdf 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 @@ -2,23 +2,25 @@ 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 com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; -import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile; -import uk.gov.hmcts.reform.fact.data.api.utils.CsvUtil; import java.io.IOException; +@Slf4j @Service public class AzureBlobService { private final BlobContainerClient blobContainerClient; + private final BlobServiceClient blobServiceClient; - public AzureBlobService(BlobContainerClient blobContainerClient) { + public AzureBlobService(BlobContainerClient blobContainerClient, BlobServiceClient blobServiceClient) { this.blobContainerClient = blobContainerClient; + this.blobServiceClient = blobServiceClient; } /** @@ -46,6 +48,27 @@ public String uploadFile(String imageId, MultipartFile file) { return blobClient.getBlobUrl(); } + public void uploadFile(String containerName, String blobName, MultipartFile file) { + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + if (!containerClient.exists()) { + containerClient.create(); + log.info("Created Azure blob container {}", containerName); + } + BlobClient blobClient = containerClient.getBlobClient(blobName); + try { + blobClient.upload(file.getInputStream(), file.getSize(), true); + + BlobHttpHeaders headers = new BlobHttpHeaders() + .setContentType(file.getContentType()); + + blobClient.setHttpHeaders(headers); + log.info("Uploaded file {} to {}", file.getOriginalFilename(), containerName); + + } catch (IOException e) { + throw new AzureUploadException("Could not upload provided file to Azure"); + } + } + /** * Delete a blob from the blob store by the imageId. * @@ -56,20 +79,4 @@ public void deleteBlob(String imageId) { blobClient.delete(); } - - - public void createCsvFileAndUpload(String containerName, String blobName, JsonNode jsonNodeData) { - uploadFile(blobName, - createCsvFile(blobName, new CsvUtil().convertJsonToCsv(jsonNodeData)) - ); - } - - public StringMultipartFile createCsvFile(String blobName, String csvString) { - return new StringMultipartFile( - blobName, - blobName, - "text/csv", - csvString - ); - } } 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 index 1374e60f..1fe1c562 100644 --- 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 @@ -1,43 +1,98 @@ package uk.gov.hmcts.reform.fact.data.api.services; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException; +import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient; +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; /** * Service for handling CSV file operations. */ +@Slf4j @Service public class CsvService { - private static final String CSV_CONTAINER_NAME = "csv"; - private static final String COURT_DATA_CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + private static final String CSV_CONTENT_TYPE = "text/csv"; + private final String csvContainerName; private final CourtService courtService; private final AzureBlobService azureBlobService; private final ObjectMapper objectMapper; + private final SlackClient slackClient; public CsvService(CourtService courtService, AzureBlobService azureBlobService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SlackClient slackClient, + @Value("${fact.data-api.csv-container-name:csv}") String csvContainerName) { this.courtService = courtService; this.azureBlobService = azureBlobService; this.objectMapper = objectMapper; + this.slackClient = slackClient; + this.csvContainerName = csvContainerName; } - public JsonNode createCsvFiles() { - JsonNode courtData = convertCourtDataToJson(); - azureBlobService.createCsvFileAndUpload(CSV_CONTAINER_NAME, COURT_DATA_CSV_FILE_NAME, courtData); - return courtData; + public void createAndUploadCsv() { + List actions = new ArrayList<>(); + StringMultipartFile csvFile = createCsvFile(actions); + uploadCsvToAzureBlob(actions, csvFile); + + sendSlackSummary(actions); } - public JsonNode convertCourtDataToJson() { + public void uploadCsvToAzureBlob(List actions, StringMultipartFile stringMultipartFile) { try { - return objectMapper.valueToTree(courtService.getAllCourtDetails()); - } catch (IllegalArgumentException ex) { - throw new JsonConvertException("Error converting court data to JSON", ex); + azureBlobService + .uploadFile(csvContainerName, 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 RuntimeException("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())) + ); + } catch (Exception e) { + log.error("Error while creating CSV file", e); + actions.add("Failed to create CSV file. Check App insights."); + throw new RuntimeException("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/CsvGenerator.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java deleted file mode 100644 index bdcf0753..00000000 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvGenerator.java +++ /dev/null @@ -1,45 +0,0 @@ -package uk.gov.hmcts.reform.fact.data.api.runners; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; -import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService; -import uk.gov.hmcts.reform.fact.data.api.services.CsvService; - -@Component -@Slf4j -public class CsvGenerator implements CommandLineRunner { - - private final AzureBlobService azureService; - private final CsvService csvService; - - public CsvGenerator(@Autowired AzureBlobService azureService, - @Autowired CsvService csvService) { - this.azureService = azureService; - this.csvService = csvService; - } - - /** - * Executes automatically when the Spring Boot application starts. - * This method is invoked as part of the application's startup process due to - * the implementation of the {@link CommandLineRunner} interface. At present, it triggers - * the generation of court and tribunal data in CSV format and uploads it to - * Azure Blob Storage. The method logs the start and completion of this process. - * - * @param args Optional command-line arguments passed during application startup. - */ - @Override - public void run(String... args) { - log.info("Running CSV generation"); - createCsvAndUpload(); - log.info("Finished running CSV generation"); - System.exit(0); - } - - public void createCsvAndUpload() { - JsonNode courtData = csvService.convertCourtDataToJson(); - azureService.createCsvFileAndUpload("csv", "courts-and-tribunals-data.csv", courtData); - } -} 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 index 9c8a0ef8..cd1edd39 100644 --- 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 @@ -76,25 +76,32 @@ private CsvSchema buildCsvSchema() { private Map flattenCourtNode(JsonNode node) { Map flatMap = new LinkedHashMap<>(); + JsonNode courtCode = getFirstArrayItem(node, "courtCodes"); + JsonNode primaryAddress = getFirstArrayItem(node, "courtAddresses"); flatMap.put("name", node.path("name").asText()); - flatMap.put("lat", node.path("lat").asDouble()); - flatMap.put("lon", node.path("lon").asDouble()); - flatMap.put("number", node.path("number").asInt()); - flatMap.put("cci_code", node.path("cci_code").asInt()); - flatMap.put("magistrate_code", node.path("magistrate_code").asInt()); + flatMap.put("lat", readDecimal(node, primaryAddress, "lat")); + flatMap.put("lon", readDecimal(node, primaryAddress, "lon")); + flatMap.put("number", readInteger(node, "number")); + flatMap.put("cci_code", readInteger(courtCode, "countyCourtCode", "cci_code")); + flatMap.put("magistrate_code", readInteger(courtCode, "magistrateCourtCode", "magistrate_code", + "magistrate_court_code")); flatMap.put("slug", node.path("slug").asText()); - flatMap.put("types", stringifyArray(node.path("types"))); - flatMap.put("open", node.path("displayed").asBoolean()); - flatMap.put("dx_number", node.path("dx_number").asText()); + flatMap.put("types", flattenTypes(node)); + flatMap.put("open", readBoolean(node, "open", "displayed")); + flatMap.put("dx_number", flattenDxCodes(node)); - flatMap.put("areas_of_law", flattenAreasOfLaw(node.path("areas_of_law"))); - flatMap.put("addresses", flattenAddresses(node.path("addresses"))); + flatMap.put("areas_of_law", flattenAreasOfLaw(node.path("courtAreasOfLaw"), node.path("areas_of_law"))); + flatMap.put("addresses", flattenAddresses(node.path("courtAddresses"), node.path("addresses"))); return flatMap; } - private String flattenAreasOfLaw(JsonNode areasOfLawNode) { + private String flattenAreasOfLaw(JsonNode... candidateNodes) { + JsonNode areasOfLawNode = getFirstArrayCandidate(candidateNodes); + if (areasOfLawNode == null) { + return "No areas of law available"; + } if (!areasOfLawNode.isArray() || areasOfLawNode.isEmpty()) { return "No areas of law available"; } @@ -115,7 +122,11 @@ private String flattenAreasOfLaw(JsonNode areasOfLawNode) { return String.join(" | ", areas); } - private String flattenAddresses(JsonNode addressesNode) { + 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"; } @@ -124,14 +135,14 @@ private String flattenAddresses(JsonNode addressesNode) { 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, "town"), + safeText(address, "townCity", "town"), safeText(address, "postcode"), - flattenAddressLines(address.path("address_lines")), - safeText(address, "type"), + flattenAddressLines(address.path("addressLines"), address.path("address_lines"), address), + safeText(address, "addressType", "type"), safeText(address, "county"), - flattenFieldsOfLaw(address.path("fields_of_law")), + flattenFieldsOfLaw(address.path("fieldsOfLaw"), address.path("fields_of_law"), address), safeText(address, "description"), - safeText(address, "epim_id") + safeText(address, "epimId", "epim_id") ); addresses.add(addressDetails); @@ -140,8 +151,21 @@ private String flattenAddresses(JsonNode addressesNode) { return String.join(" | ", addresses); } - private String flattenAddressLines(JsonNode lines) { - if (!lines.isArray() || lines.isEmpty()) { + 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"; } @@ -150,8 +174,26 @@ private String flattenAddressLines(JsonNode lines) { return String.join(", ", lineList); } - private String flattenFieldsOfLaw(JsonNode fieldsOfLawNode) { + private String flattenFieldsOfLaw(JsonNode... candidateNodes) { + JsonNode fieldsOfLawNode = getFirstObjectCandidate(candidateNodes); + if (fieldsOfLawNode == null) { + JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; + if (addressNode != null && addressNode.isObject()) { + List parts = new ArrayList<>(); + addNamesFromArray(parts, addressNode.path("areasOfLaw"), "Areas of Law"); + addNamesFromArray(parts, addressNode.path("courtTypes"), "Courts"); + return parts.isEmpty() ? "N/A" : String.join(", ", parts); + } + return "N/A"; + } if (!fieldsOfLawNode.isObject()) { + JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; + if (addressNode != null && addressNode.isObject()) { + List parts = new ArrayList<>(); + addNamesFromArray(parts, addressNode.path("areasOfLaw"), "Areas of Law"); + addNamesFromArray(parts, addressNode.path("courtTypes"), "Courts"); + return parts.isEmpty() ? "N/A" : String.join(", ", parts); + } return "N/A"; } @@ -174,6 +216,113 @@ private String flattenFieldsOfLaw(JsonNode fieldsOfLawNode) { return String.join(", ", parts); } + 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 (!"N/A".equals(value)) { + values.add(value); + } + } + if (!values.isEmpty()) { + return String.join(" | ", 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<>(); + JsonNode addresses = node.path("courtAddresses"); + if (addresses.isArray()) { + for (JsonNode address : addresses) { + JsonNode courtTypes = address.path("courtTypes"); + if (courtTypes.isArray()) { + for (JsonNode courtType : courtTypes) { + if (courtType.isTextual()) { + types.add(courtType.asText()); + } else { + String name = safeText(courtType, "name"); + if (!"N/A".equals(name)) { + types.add(name); + } + } + } + } + } + } + return String.join(" | ", types); + } + + 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 ""; @@ -183,8 +332,41 @@ private String stringifyArray(JsonNode arrayNode) { return String.join(" | ", items); } - private String safeText(JsonNode node, String fieldName) { - JsonNode field = node.path(fieldName); - return field.isMissingNode() || field.isNull() ? "N/A" : field.asText(); + private String safeText(JsonNode node, String... fieldNames) { + if (node == null) { + return "N/A"; + } + for (String fieldName : fieldNames) { + JsonNode field = node.path(fieldName); + if (!field.isMissingNode() && !field.isNull()) { + return field.asText(); + } + } + return "N/A"; + } + + private void addIfPresent(List values, String value) { + if (value != null && !value.isBlank() && !"N/A".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 (!"N/A".equals(name)) { + names.add(name); + } + } + }); + if (!names.isEmpty()) { + parts.add(label + ": " + String.join(" | ", names)); + } + } } } 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/services/AzureBlobServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java index 1f6a0034..c9e8c380 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,6 +2,7 @@ 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.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,12 +28,16 @@ class AzureBlobServiceTest { private static final String IMAGE_ID = "test-image-id"; + private static final String CONTAINER_NAME = "csv"; private static final String CONTENT_TYPE = "image/jpeg"; private static final String BLOB_URL = "https://example.com/blob/test-image-id"; @Mock private BlobContainerClient blobContainerClient; + @Mock + private BlobServiceClient blobServiceClient; + @Mock private BlobClient blobClient; @@ -85,4 +90,55 @@ void deleteBlobShouldDeleteBlobById() { verify(blobClient).delete(); } + + @Test + void uploadFileWithContainerShouldUploadToProvidedContainer() throws IOException { + byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); + InputStream inputStream = new ByteArrayInputStream(fileBytes); + + when(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); + when(blobContainerClient.exists()).thenReturn(true); + 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); + + azureBlobService.uploadFile(CONTAINER_NAME, IMAGE_ID, multipartFile); + + 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(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); + when(blobContainerClient.exists()).thenReturn(false); + 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); + + azureBlobService.uploadFile(CONTAINER_NAME, IMAGE_ID, multipartFile); + + verify(blobContainerClient).create(); + verify(blobClient).upload(inputStream, fileBytes.length, true); + verify(blobClient).setHttpHeaders(org.mockito.ArgumentMatchers.any(BlobHttpHeaders.class)); + } + + @Test + void uploadFileWithContainerShouldThrowAzureUploadExceptionWhenReadingFails() throws IOException { + when(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); + when(blobContainerClient.exists()).thenReturn(true); + when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); + when(multipartFile.getInputStream()).thenThrow(new IOException("Read failure")); + + assertThrows(AzureUploadException.class, () -> + azureBlobService.uploadFile(CONTAINER_NAME, 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..013f094e --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java @@ -0,0 +1,85 @@ +package uk.gov.hmcts.reform.fact.data.api.services; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.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.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_CONTAINER_NAME = "csv-container"; + private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; + + @Mock + private CourtService courtService; + + @Mock + private AzureBlobService azureBlobService; + + @Mock + private SlackClient slackClient; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @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_CONTAINER_NAME, 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_CONTAINER_NAME, CSV_FILE_NAME, csvFile); + + RuntimeException exception = assertThrows(RuntimeException.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_CONTAINER_NAME), + org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + verify(slackClient, never()).sendSlackMessage(any()); + } + + private CsvService buildService() { + return new CsvService(courtService, azureBlobService, objectMapper, slackClient, CSV_CONTAINER_NAME); + } +} From 9ee8ffe22cffd19680c280646a878a779b13999f Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Fri, 8 May 2026 11:35:12 +0100 Subject: [PATCH 04/18] Fix slack message alert --- .../fact/data/api/services/CsvService.java | 10 ++++--- .../data/api/services/CsvServiceTest.java | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) 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 index 1fe1c562..857255e1 100644 --- 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 @@ -45,10 +45,12 @@ public CsvService(CourtService courtService, public void createAndUploadCsv() { List actions = new ArrayList<>(); - StringMultipartFile csvFile = createCsvFile(actions); - uploadCsvToAzureBlob(actions, csvFile); - - sendSlackSummary(actions); + try { + StringMultipartFile csvFile = createCsvFile(actions); + uploadCsvToAzureBlob(actions, csvFile); + } finally { + sendSlackSummary(actions); + } } public void uploadCsvToAzureBlob(List actions, StringMultipartFile stringMultipartFile) { 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 index 013f094e..5fcc3dfa 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -79,6 +80,33 @@ void createAndUploadCsvShouldCreateAndUploadWithoutSlackMessageOnSuccess() { verify(slackClient, never()).sendSlackMessage(any()); } + @Test + void createAndUploadCsvShouldSendSlackMessageAndThrowWhenCsvCreationFails() { + CsvService csvService = buildService(); + when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("court failure")); + + RuntimeException exception = assertThrows(RuntimeException.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_CONTAINER_NAME), + org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + + RuntimeException exception = assertThrows(RuntimeException.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, azureBlobService, objectMapper, slackClient, CSV_CONTAINER_NAME); } From af9c3e921227d16600fad81aebfc49c167adc57f Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Tue, 12 May 2026 17:13:12 +0100 Subject: [PATCH 05/18] Update court data flow --- .../fact/data/api/services/CsvService.java | 10 +- .../reform/fact/data/api/utils/CsvUtil.java | 80 +++++++++------- .../data/api/services/CsvServiceTest.java | 6 +- .../fact/data/api/utils/CsvUtilTest.java | 91 +++++++++++++++++++ 4 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java 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 index 857255e1..5177b467 100644 --- 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 @@ -27,16 +27,19 @@ public class CsvService { private final String csvContainerName; 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, AzureBlobService azureBlobService, ObjectMapper objectMapper, SlackClient slackClient, @Value("${fact.data-api.csv-container-name:csv}") String csvContainerName) { this.courtService = courtService; + this.courtDetailsViewService = courtDetailsViewService; this.azureBlobService = azureBlobService; this.objectMapper = objectMapper; this.slackClient = slackClient; @@ -70,7 +73,12 @@ public StringMultipartFile createCsvFile(List actions) { CSV_FILE_NAME, CSV_FILE_NAME, CSV_CONTENT_TYPE, - new CsvUtil().convertJsonToCsv(objectMapper.valueToTree(courtService.getAllCourtDetails())) + new CsvUtil() + .convertJsonToCsv( + objectMapper.valueToTree( + courtService.getAllCourtDetails().stream().map( + courtDetailsViewService::prepareDetailsView) + .toList())) ); } catch (Exception e) { log.error("Error while creating CSV file", e); 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 index cd1edd39..3c2420f1 100644 --- 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 @@ -74,7 +74,7 @@ private CsvSchema buildCsvSchema() { .withHeader(); } - private Map flattenCourtNode(JsonNode node) { + public Map flattenCourtNode(JsonNode node) { Map flatMap = new LinkedHashMap<>(); JsonNode courtCode = getFirstArrayItem(node, "courtCodes"); JsonNode primaryAddress = getFirstArrayItem(node, "courtAddresses"); @@ -88,7 +88,7 @@ private Map flattenCourtNode(JsonNode node) { "magistrate_court_code")); flatMap.put("slug", node.path("slug").asText()); flatMap.put("types", flattenTypes(node)); - flatMap.put("open", readBoolean(node, "open", "displayed")); + 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"))); @@ -98,28 +98,30 @@ private Map flattenCourtNode(JsonNode node) { } private String flattenAreasOfLaw(JsonNode... candidateNodes) { - JsonNode areasOfLawNode = getFirstArrayCandidate(candidateNodes); - if (areasOfLawNode == null) { - return "No areas of law available"; - } - if (!areasOfLawNode.isArray() || areasOfLawNode.isEmpty()) { + JsonNode courtAreasOfLaw = getFirstArrayCandidate(candidateNodes); + if (courtAreasOfLaw == null || courtAreasOfLaw.isEmpty()) { return "No areas of law available"; } List areas = new ArrayList<>(); - for (JsonNode area : areasOfLawNode) { - String areaDetails = String.format( - "Name: %s, External Link: %s, Description: %s, Display Name: %s, Display External Link: %s", - safeText(area, "name"), - safeText(area, "external_link"), - safeText(area, "external_link_desc"), - safeText(area, "display_name"), - safeText(area, "display_external_link") - ); - areas.add(areaDetails); + for (JsonNode courtArea : courtAreasOfLaw) { + JsonNode areaList = courtArea.path("areasOfLaw"); + 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 String.join(" | ", areas); + return areas.isEmpty() ? "No areas of law available" : String.join(" | ", areas); } private String flattenAddresses(JsonNode... candidateNodes) { @@ -176,17 +178,7 @@ private String flattenAddressLines(JsonNode... candidateNodes) { private String flattenFieldsOfLaw(JsonNode... candidateNodes) { JsonNode fieldsOfLawNode = getFirstObjectCandidate(candidateNodes); - if (fieldsOfLawNode == null) { - JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; - if (addressNode != null && addressNode.isObject()) { - List parts = new ArrayList<>(); - addNamesFromArray(parts, addressNode.path("areasOfLaw"), "Areas of Law"); - addNamesFromArray(parts, addressNode.path("courtTypes"), "Courts"); - return parts.isEmpty() ? "N/A" : String.join(", ", parts); - } - return "N/A"; - } - if (!fieldsOfLawNode.isObject()) { + if (fieldsOfLawNode == null || !fieldsOfLawNode.isObject()) { JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; if (addressNode != null && addressNode.isObject()) { List parts = new ArrayList<>(); @@ -199,17 +191,23 @@ private String flattenFieldsOfLaw(JsonNode... candidateNodes) { List parts = new ArrayList<>(); - JsonNode areas = fieldsOfLawNode.path("areas_of_law"); + JsonNode areas = fieldsOfLawNode.path("areasOfLaw"); + if (areas.isMissingNode()) { + areas = fieldsOfLawNode.path("areas_of_law"); + } if (areas.isArray() && !areas.isEmpty()) { List names = new ArrayList<>(); - areas.forEach(a -> names.add(a.asText())); + areas.forEach(a -> names.add(a.isTextual() ? a.asText() : safeText(a, "name"))); parts.add("Areas of Law: " + String.join(" | ", names)); } JsonNode courts = fieldsOfLawNode.path("courts"); + if (courts.isMissingNode()) { + courts = fieldsOfLawNode.path("courtTypes"); + } if (courts.isArray() && !courts.isEmpty()) { List names = new ArrayList<>(); - courts.forEach(c -> names.add(c.asText())); + courts.forEach(c -> names.add(c.isTextual() ? c.asText() : safeText(c, "name"))); parts.add("Courts: " + String.join(" | ", names)); } @@ -257,6 +255,24 @@ private String flattenTypes(JsonNode node) { } } } + + if (types.isEmpty()) { + JsonNode counterServiceOpeningHours = node.path("courtCounterServiceOpeningHours"); + if (counterServiceOpeningHours.isArray()) { + for (JsonNode openingHour : counterServiceOpeningHours) { + JsonNode courtTypes = openingHour.path("courtTypes"); + if (courtTypes.isArray()) { + for (JsonNode courtType : courtTypes) { + String name = courtType.isTextual() ? courtType.asText() : safeText(courtType, "name"); + if (!"N/A".equals(name) && !types.contains(name)) { + types.add(name); + } + } + } + } + } + } + return String.join(" | ", types); } 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 index 5fcc3dfa..853128b9 100644 --- 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 @@ -30,6 +30,9 @@ class CsvServiceTest { @Mock private CourtService courtService; + @Mock + private CourtDetailsViewService courtDetailsViewService; + @Mock private AzureBlobService azureBlobService; @@ -108,6 +111,7 @@ void createAndUploadCsvShouldSendSlackMessageAndThrowWhenUploadFails() { } private CsvService buildService() { - return new CsvService(courtService, azureBlobService, objectMapper, slackClient, CSV_CONTAINER_NAME); + return new CsvService( + courtService, courtDetailsViewService, azureBlobService, objectMapper, slackClient, CSV_CONTAINER_NAME); } } 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..851a8086 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java @@ -0,0 +1,91 @@ +package uk.gov.hmcts.reform.fact.data.api.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CsvUtilTest { + + private final ObjectMapper mapper = new ObjectMapper(); + 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"); + assertThat(addressesStr).contains("Town: London"); + assertThat(addressesStr).contains("Postcode: SW1 1AA"); + assertThat(addressesStr).contains("Areas of Law: Crime"); + assertThat(addressesStr).contains("Courts: Crown Court"); + + // Verify types flattening as well + assertThat(result.get("types").toString()).isEqualTo("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").toString()).isEqualTo("Magistrates' Court"); + } + + @Test + void shouldHandleOpenOnCath() { + ObjectNode root = mapper.createObjectNode(); + root.put("openOnCath", true); + + Map result = csvUtil.flattenCourtNode(root); + + assertThat(result.get("open")).isEqualTo(true); + } +} From 388266d0fee1f418b6bd9c96c194f220613e3f0c Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 10:46:11 +0100 Subject: [PATCH 06/18] update csv formatter --- .../reform/fact/data/api/controllers/CsvControllerTest.java | 2 +- .../java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index f82af803..28b742a2 100644 --- 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 @@ -4,7 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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; 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 index 3c2420f1..518156fc 100644 --- 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 @@ -82,7 +82,7 @@ public Map flattenCourtNode(JsonNode node) { 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(node, "number")); + flatMap.put("number", readInteger(courtCode, "crownCourtCode", "crown_court_code")); flatMap.put("cci_code", readInteger(courtCode, "countyCourtCode", "cci_code")); flatMap.put("magistrate_code", readInteger(courtCode, "magistrateCourtCode", "magistrate_code", "magistrate_court_code")); From 75d3a61149fcef01d2a1f96517ffa4f68df5948a Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 11:08:24 +0100 Subject: [PATCH 07/18] Update CsvUtil.java --- .../reform/fact/data/api/utils/CsvUtil.java | 124 ++++++++++-------- 1 file changed, 70 insertions(+), 54 deletions(-) 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 index 518156fc..75d02f2d 100644 --- 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 @@ -29,6 +29,22 @@ * 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 AREAS_OF_LAW = "areas_of_law"; + 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; @@ -58,18 +74,18 @@ public String convertJsonToCsv(JsonNode jsonArrayNode) { private CsvSchema buildCsvSchema() { return CsvSchema.builder() - .addColumn("name") - .addColumn("lat") - .addColumn("lon") + .addColumn(NAME) + .addColumn(LAT) + .addColumn(LON) .addColumn("number") .addColumn("cci_code") .addColumn("magistrate_code") - .addColumn("slug") - .addColumn("types") + .addColumn(SLUG) + .addColumn(TYPES) .addColumn("open") - .addColumn("dx_number") - .addColumn("areas_of_law") - .addColumn("addresses") + .addColumn(DX_NUMBER) + .addColumn(AREAS_OF_LAW) + .addColumn(ADDRESSES) .build() .withHeader(); } @@ -77,22 +93,22 @@ private CsvSchema buildCsvSchema() { public Map flattenCourtNode(JsonNode node) { Map flatMap = new LinkedHashMap<>(); JsonNode courtCode = getFirstArrayItem(node, "courtCodes"); - JsonNode primaryAddress = getFirstArrayItem(node, "courtAddresses"); + 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(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", "cci_code")); flatMap.put("magistrate_code", readInteger(courtCode, "magistrateCourtCode", "magistrate_code", "magistrate_court_code")); - flatMap.put("slug", node.path("slug").asText()); - flatMap.put("types", flattenTypes(node)); + 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(DX_NUMBER, flattenDxCodes(node)); - flatMap.put("areas_of_law", flattenAreasOfLaw(node.path("courtAreasOfLaw"), node.path("areas_of_law"))); - flatMap.put("addresses", flattenAddresses(node.path("courtAddresses"), node.path("addresses"))); + 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; } @@ -100,7 +116,7 @@ public Map flattenCourtNode(JsonNode node) { private String flattenAreasOfLaw(JsonNode... candidateNodes) { JsonNode courtAreasOfLaw = getFirstArrayCandidate(candidateNodes); if (courtAreasOfLaw == null || courtAreasOfLaw.isEmpty()) { - return "No areas of law available"; + return NO_AREAS_OF_LAW_AVAILABLE; } List areas = new ArrayList<>(); @@ -110,7 +126,7 @@ private String flattenAreasOfLaw(JsonNode... candidateNodes) { 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, NAME), safeText(area, "externalLink", "external_link"), safeText(area, "externalLinkDesc", "external_link_desc"), safeText(area, "displayName", "display_name"), @@ -121,16 +137,16 @@ private String flattenAreasOfLaw(JsonNode... candidateNodes) { } } - return areas.isEmpty() ? "No areas of law available" : String.join(" | ", areas); + 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"; + return NO_ADDRESS_AVAILABLE; } if (!addressesNode.isArray() || addressesNode.isEmpty()) { - return "No address available"; + return NO_ADDRESS_AVAILABLE; } List addresses = new ArrayList<>(); @@ -150,7 +166,7 @@ private String flattenAddresses(JsonNode... candidateNodes) { addresses.add(addressDetails); } - return String.join(" | ", addresses); + return String.join(PIPE_SEPARATOR, addresses); } private String flattenAddressLines(JsonNode... candidateNodes) { @@ -182,33 +198,33 @@ private String flattenFieldsOfLaw(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("areasOfLaw"), "Areas of Law"); - addNamesFromArray(parts, addressNode.path("courtTypes"), "Courts"); - return parts.isEmpty() ? "N/A" : String.join(", ", parts); + addNamesFromArray(parts, addressNode.path("areasOfLaw"), AREAS_OF_LAW_LABEL); + addNamesFromArray(parts, addressNode.path(COURT_TYPES), COURTS_LABEL); + return parts.isEmpty() ? NOT_AVAILABLE : String.join(", ", parts); } - return "N/A"; + return NOT_AVAILABLE; } List parts = new ArrayList<>(); JsonNode areas = fieldsOfLawNode.path("areasOfLaw"); if (areas.isMissingNode()) { - areas = fieldsOfLawNode.path("areas_of_law"); + 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: " + String.join(" | ", names)); + areas.forEach(a -> names.add(a.isTextual() ? a.asText() : safeText(a, NAME))); + parts.add(AREAS_OF_LAW_LABEL + ": " + String.join(PIPE_SEPARATOR, names)); } JsonNode courts = fieldsOfLawNode.path("courts"); if (courts.isMissingNode()) { - courts = fieldsOfLawNode.path("courtTypes"); + 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: " + String.join(" | ", names)); + courts.forEach(c -> names.add(c.isTextual() ? c.asText() : safeText(c, NAME))); + parts.add(COURTS_LABEL + ": " + String.join(PIPE_SEPARATOR, names)); } return String.join(", ", parts); @@ -219,35 +235,35 @@ private String flattenDxCodes(JsonNode node) { if (dxCodes.isArray() && !dxCodes.isEmpty()) { List values = new ArrayList<>(); for (JsonNode dxCode : dxCodes) { - String value = safeText(dxCode, "dxCode", "dx_number"); - if (!"N/A".equals(value)) { + String value = safeText(dxCode, "dxCode", DX_NUMBER); + if (!NOT_AVAILABLE.equals(value)) { values.add(value); } } if (!values.isEmpty()) { - return String.join(" | ", values); + return String.join(PIPE_SEPARATOR, values); } } - return node.path("dx_number").asText(); + return node.path(DX_NUMBER).asText(); } private String flattenTypes(JsonNode node) { - if (node.path("types").isArray()) { - return stringifyArray(node.path("types")); + if (node.path(TYPES).isArray()) { + return stringifyArray(node.path(TYPES)); } List types = new ArrayList<>(); - JsonNode addresses = node.path("courtAddresses"); + JsonNode addresses = node.path(COURT_ADDRESSES); if (addresses.isArray()) { for (JsonNode address : addresses) { - JsonNode courtTypes = address.path("courtTypes"); + JsonNode courtTypes = address.path(COURT_TYPES); if (courtTypes.isArray()) { for (JsonNode courtType : courtTypes) { if (courtType.isTextual()) { types.add(courtType.asText()); } else { - String name = safeText(courtType, "name"); - if (!"N/A".equals(name)) { + String name = safeText(courtType, NAME); + if (!NOT_AVAILABLE.equals(name)) { types.add(name); } } @@ -260,11 +276,11 @@ private String flattenTypes(JsonNode node) { JsonNode counterServiceOpeningHours = node.path("courtCounterServiceOpeningHours"); if (counterServiceOpeningHours.isArray()) { for (JsonNode openingHour : counterServiceOpeningHours) { - JsonNode courtTypes = openingHour.path("courtTypes"); + JsonNode courtTypes = openingHour.path(COURT_TYPES); if (courtTypes.isArray()) { for (JsonNode courtType : courtTypes) { - String name = courtType.isTextual() ? courtType.asText() : safeText(courtType, "name"); - if (!"N/A".equals(name) && !types.contains(name)) { + String name = courtType.isTextual() ? courtType.asText() : safeText(courtType, NAME); + if (!NOT_AVAILABLE.equals(name) && !types.contains(name)) { types.add(name); } } @@ -273,7 +289,7 @@ private String flattenTypes(JsonNode node) { } } - return String.join(" | ", types); + return String.join(PIPE_SEPARATOR, types); } private JsonNode getFirstArrayItem(JsonNode node, String field) { @@ -345,12 +361,12 @@ private String stringifyArray(JsonNode arrayNode) { } List items = new ArrayList<>(); arrayNode.forEach(n -> items.add(n.asText())); - return String.join(" | ", items); + return String.join(PIPE_SEPARATOR, items); } private String safeText(JsonNode node, String... fieldNames) { if (node == null) { - return "N/A"; + return NOT_AVAILABLE; } for (String fieldName : fieldNames) { JsonNode field = node.path(fieldName); @@ -358,11 +374,11 @@ private String safeText(JsonNode node, String... fieldNames) { return field.asText(); } } - return "N/A"; + return NOT_AVAILABLE; } private void addIfPresent(List values, String value) { - if (value != null && !value.isBlank() && !"N/A".equals(value)) { + if (value != null && !value.isBlank() && !NOT_AVAILABLE.equals(value)) { values.add(value); } } @@ -374,14 +390,14 @@ private void addNamesFromArray(List parts, JsonNode arrayNode, String la if (item.isTextual()) { names.add(item.asText()); } else { - String name = safeText(item, "name"); - if (!"N/A".equals(name)) { + String name = safeText(item, NAME); + if (!NOT_AVAILABLE.equals(name)) { names.add(name); } } }); if (!names.isEmpty()) { - parts.add(label + ": " + String.join(" | ", names)); + parts.add(label + ": " + String.join(PIPE_SEPARATOR, names)); } } } From b1266a16cab72c3d88e91738c4a67791e20a9591 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 11:25:43 +0100 Subject: [PATCH 08/18] Add test file --- .../reform/fact/data/api/utils/CsvUtil.java | 12 ++-- .../api/models/StringMultipartFileTest.java | 71 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/test/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFileTest.java 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 index 75d02f2d..8db01e26 100644 --- 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 @@ -34,6 +34,8 @@ public class CsvUtil { 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 ADDRESSES = "addresses"; private static final String COURT_ADDRESSES = "courtAddresses"; @@ -78,8 +80,8 @@ private CsvSchema buildCsvSchema() { .addColumn(LAT) .addColumn(LON) .addColumn("number") - .addColumn("cci_code") - .addColumn("magistrate_code") + .addColumn(CCI_CODE) + .addColumn(MAGISTRATE_CODE) .addColumn(SLUG) .addColumn(TYPES) .addColumn("open") @@ -99,9 +101,9 @@ public Map flattenCourtNode(JsonNode node) { 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", "cci_code")); - flatMap.put("magistrate_code", readInteger(courtCode, "magistrateCourtCode", "magistrate_code", - "magistrate_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")); 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); + } +} + From 2ad3866714f33b09ede71d7bf506cbb14e878c32 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 12:05:52 +0100 Subject: [PATCH 09/18] fix sonar cloud --- .../reform/fact/data/api/utils/CsvUtil.java | 82 +++++++------ .../fact/data/api/utils/CsvUtilTest.java | 108 ++++++++++++++++++ 2 files changed, 156 insertions(+), 34 deletions(-) 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 index 8db01e26..935824bd 100644 --- 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 @@ -37,6 +37,7 @@ public class CsvUtil { 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"; @@ -123,7 +124,7 @@ private String flattenAreasOfLaw(JsonNode... candidateNodes) { List areas = new ArrayList<>(); for (JsonNode courtArea : courtAreasOfLaw) { - JsonNode areaList = courtArea.path("areasOfLaw"); + JsonNode areaList = courtArea.path(AREAS_OF_LAW_PATH); if (areaList.isArray()) { for (JsonNode area : areaList) { String areaDetails = String.format( @@ -197,19 +198,29 @@ private String flattenAddressLines(JsonNode... candidateNodes) { private String flattenFieldsOfLaw(JsonNode... candidateNodes) { JsonNode fieldsOfLawNode = getFirstObjectCandidate(candidateNodes); if (fieldsOfLawNode == null || !fieldsOfLawNode.isObject()) { - JsonNode addressNode = candidateNodes.length > 0 ? candidateNodes[candidateNodes.length - 1] : null; - if (addressNode != null && addressNode.isObject()) { - List parts = new ArrayList<>(); - addNamesFromArray(parts, addressNode.path("areasOfLaw"), AREAS_OF_LAW_LABEL); - addNamesFromArray(parts, addressNode.path(COURT_TYPES), COURTS_LABEL); - return parts.isEmpty() ? NOT_AVAILABLE : String.join(", ", parts); - } - return NOT_AVAILABLE; + 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; + } - JsonNode areas = fieldsOfLawNode.path("areasOfLaw"); + private void addFieldsOfLawAreas(List parts, JsonNode fieldsOfLawNode) { + JsonNode areas = fieldsOfLawNode.path(AREAS_OF_LAW_PATH); if (areas.isMissingNode()) { areas = fieldsOfLawNode.path(AREAS_OF_LAW); } @@ -218,7 +229,9 @@ private String flattenFieldsOfLaw(JsonNode... candidateNodes) { 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); @@ -228,8 +241,6 @@ private String flattenFieldsOfLaw(JsonNode... candidateNodes) { courts.forEach(c -> names.add(c.isTextual() ? c.asText() : safeText(c, NAME))); parts.add(COURTS_LABEL + ": " + String.join(PIPE_SEPARATOR, names)); } - - return String.join(", ", parts); } private String flattenDxCodes(JsonNode node) { @@ -255,43 +266,46 @@ private String flattenTypes(JsonNode node) { } List types = new ArrayList<>(); - JsonNode addresses = node.path(COURT_ADDRESSES); + 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) { - if (courtType.isTextual()) { - types.add(courtType.asText()); - } else { - String name = safeText(courtType, NAME); - if (!NOT_AVAILABLE.equals(name)) { - types.add(name); - } - } + addTypeNameToList(courtType, types); } } } } + } - if (types.isEmpty()) { - JsonNode counterServiceOpeningHours = node.path("courtCounterServiceOpeningHours"); - if (counterServiceOpeningHours.isArray()) { - for (JsonNode openingHour : counterServiceOpeningHours) { - JsonNode courtTypes = openingHour.path(COURT_TYPES); - if (courtTypes.isArray()) { - for (JsonNode courtType : courtTypes) { - String name = courtType.isTextual() ? courtType.asText() : safeText(courtType, NAME); - if (!NOT_AVAILABLE.equals(name) && !types.contains(name)) { - types.add(name); - } - } + 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); } } } } + } - return String.join(PIPE_SEPARATOR, 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) { 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 index 851a8086..c95fa5f4 100644 --- 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 @@ -88,4 +88,112 @@ void shouldHandleOpenOnCath() { assertThat(result.get("open")).isEqualTo(true); } + + @Test + void shouldReturnNotAvailableIfNoNodesProvided() { + Map result = csvUtil.flattenCourtNode(mapper.createObjectNode()); + assertThat(result.get("addresses").toString()).isEqualTo("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").toString()).isEqualTo("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").toString()).isEqualTo("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").toString()).isEqualTo("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"); + assertThat(addressesStr).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"); + assertThat(addressesStr).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"); + assertThat(addressesStr).contains("Courts: Crown Court"); + } } From 79a353391312793fee23d55c97a2e4bc6f060b6d Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 12:25:22 +0100 Subject: [PATCH 10/18] increase test coverage --- .../GlobalExceptionHandlerTest.java | 22 ++-- .../fact/data/api/utils/CsvUtilTest.java | 107 ++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) 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..0bb2cd9a 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,8 @@ 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.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.errorhandling.exceptions.*; + import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidUUID; import java.lang.annotation.Annotation; @@ -327,6 +321,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/utils/CsvUtilTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtilTest.java index c95fa5f4..6d1de4f2 100644 --- 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 @@ -1,13 +1,20 @@ package uk.gov.hmcts.reform.fact.data.api.utils; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.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.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class CsvUtilTest { @@ -196,4 +203,104 @@ void shouldFallbackToAddressNodeWhenFieldsOfLawNodeMissing() { assertThat(addressesStr).contains("Areas of Law: Crime"); assertThat(addressesStr).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"); + assertThat(csv).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.get("dx_number")).isEqualTo("DX 123"); + + ArrayNode dxCodes = root.putArray("courtDxCodes"); + ObjectNode dx1 = dxCodes.addObject(); + dx1.put("dxCode", "DX 456"); + + result = csvUtil.flattenCourtNode(root); + assertThat(result.get("dx_number")).isEqualTo("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.get("open")).isEqualTo(true); + assertThat(result.get("number")).isEqualTo(123); + assertThat(result.get("cci_code")).isEqualTo(456); + assertThat(result.get("magistrate_code")).isEqualTo(789); + assertThat(result.get("lat")).isEqualTo(51.5); + assertThat(result.get("lon")).isEqualTo(-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.get("open")).isEqualTo(false); + } + + @Test + void shouldThrowJsonConvertExceptionWhenCsvWritingFails() throws Exception { + CsvMapper mockCsvMapper = mock(CsvMapper.class); + CsvUtil utilWithMock = new CsvUtil(mockCsvMapper); + + when(mockCsvMapper.writer(org.mockito.ArgumentMatchers.any(com.fasterxml.jackson.dataformat.csv.CsvSchema.class))) + .thenThrow(new RuntimeException("Mock failure")); + + ArrayNode root = mapper.createArrayNode(); + root.addObject(); + + assertThatThrownBy(() -> utilWithMock.convertJsonToCsv(root)) + .isInstanceOf(JsonConvertException.class) + .hasMessageContaining("Failed to convert JSON to CSV: Mock failure"); + } } From 8e43a4b81d149181e90f98dffb2373beaf0e226b Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 12:31:10 +0100 Subject: [PATCH 11/18] Fix build --- .../api/errorhandling/GlobalExceptionHandlerTest.java | 9 ++++++++- .../hmcts/reform/fact/data/api/utils/CsvUtilTest.java | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) 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 0bb2cd9a..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,7 +15,14 @@ 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.*; +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; 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 index 6d1de4f2..caff3f13 100644 --- 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 @@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -213,7 +212,8 @@ void shouldConvertJsonToCsv() { 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("name,lat,lon,number,cci_code,magistrate_code,slug,types,open,dx_number,areas_of_law,addresses"); assertThat(csv).contains("Test Court"); assertThat(csv).contains("test-court"); } @@ -293,7 +293,8 @@ void shouldThrowJsonConvertExceptionWhenCsvWritingFails() throws Exception { CsvMapper mockCsvMapper = mock(CsvMapper.class); CsvUtil utilWithMock = new CsvUtil(mockCsvMapper); - when(mockCsvMapper.writer(org.mockito.ArgumentMatchers.any(com.fasterxml.jackson.dataformat.csv.CsvSchema.class))) + when(mockCsvMapper.writer(org.mockito.ArgumentMatchers.any( + com.fasterxml.jackson.dataformat.csv.CsvSchema.class))) .thenThrow(new RuntimeException("Mock failure")); ArrayNode root = mapper.createArrayNode(); From 1498f22d6cc8339048604034de4e0348e8630b1e Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 14:13:20 +0100 Subject: [PATCH 12/18] Specify exceptions --- .../exceptions/CsvCreationException.java | 7 +++++++ .../reform/fact/data/api/services/CsvService.java | 6 ++++-- .../exceptions/CustomExceptionsTest.java | 12 ++++++++++++ .../fact/data/api/services/CsvServiceTest.java | 8 +++++--- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java 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/services/CsvService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java index 5177b467..ce506573 100644 --- 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 @@ -6,6 +6,8 @@ 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; @@ -63,7 +65,7 @@ public void uploadCsvToAzureBlob(List actions, StringMultipartFile strin } 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 RuntimeException("Failed to upload CSV file to Azure Blob Storage", e); + throw new AzureUploadException("Failed to upload CSV file to Azure Blob Storage", e); } } @@ -83,7 +85,7 @@ public StringMultipartFile createCsvFile(List actions) { } catch (Exception e) { log.error("Error while creating CSV file", e); actions.add("Failed to create CSV file. Check App insights."); - throw new RuntimeException("Failed to create CSV file", e); + throw new CsvCreationException("Failed to create CSV file", e); } } 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/services/CsvServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CsvServiceTest.java index 853128b9..0a401cea 100644 --- 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 @@ -6,6 +6,8 @@ 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; @@ -63,7 +65,7 @@ void uploadCsvToAzureBlobShouldAppendActionAndThrowWhenUploadFails() { .when(azureBlobService) .uploadFile(CSV_CONTAINER_NAME, CSV_FILE_NAME, csvFile); - RuntimeException exception = assertThrows(RuntimeException.class, () -> + AzureUploadException exception = assertThrows(AzureUploadException.class, () -> csvService.uploadCsvToAzureBlob(actions, csvFile) ); @@ -88,7 +90,7 @@ void createAndUploadCsvShouldSendSlackMessageAndThrowWhenCsvCreationFails() { CsvService csvService = buildService(); when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("court failure")); - RuntimeException exception = assertThrows(RuntimeException.class, csvService::createAndUploadCsv); + 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.")); @@ -103,7 +105,7 @@ void createAndUploadCsvShouldSendSlackMessageAndThrowWhenUploadFails() { .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_CONTAINER_NAME), org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); - RuntimeException exception = assertThrows(RuntimeException.class, csvService::createAndUploadCsv); + AzureUploadException exception = assertThrows(AzureUploadException.class, csvService::createAndUploadCsv); assertThat(exception.getMessage()).isEqualTo("Failed to upload CSV file to Azure Blob Storage"); verify(slackClient) From 488029ee2543e9f033d1772571ea29ca33774da2 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 14:47:16 +0100 Subject: [PATCH 13/18] Update exception handling --- .../api/controllers/CsvControllerTest.java | 28 ++++++++++++++----- .../data/api/controllers/CsvController.java | 3 +- .../errorhandling/GlobalExceptionHandler.java | 16 +++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) 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 index 28b742a2..e713e53a 100644 --- 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 @@ -9,12 +9,14 @@ 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.errorhandling.exceptions.AzureUploadException; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException; import uk.gov.hmcts.reform.fact.data.api.services.CsvService; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; 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") @@ -40,14 +42,26 @@ void createAndUploadCsvReturns200() throws Exception { } @Test - @DisplayName("POST /csv/ propagates exception when CSV creation/upload fails") - void createAndUploadCsvThrowsException() { - doThrow(new RuntimeException("Failed to create or upload CSV file")) + @DisplayName("POST /csv/ returns 502 when Azure upload fails") + void createAndUploadCsvReturns502OnAzureUploadException() throws Exception { + doThrow(new AzureUploadException("Failed to upload CSV file to Azure Blob Storage")) .when(csvService) .createAndUploadCsv(); - assertThatThrownBy(() -> mvc.perform(post("/csv/"))) - .hasRootCauseInstanceOf(RuntimeException.class) - .hasMessageContaining("Failed to create or upload CSV file"); + 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 { + doThrow(new CsvCreationException("Failed to create CSV file")) + .when(csvService) + .createAndUploadCsv(); + + 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/controllers/CsvController.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java index b24f7ac6..f9d606d5 100644 --- 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 @@ -28,7 +28,8 @@ public CsvController(CsvService csvService) { ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "CSV file created and uploaded successfully"), - @ApiResponse(responseCode = "500", description = "Failed to create or upload CSV file") + @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 15a2ace3..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,6 +1,8 @@ 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; @@ -215,6 +217,20 @@ public ExceptionResponse handle(JsonConvertException ex) { 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); From de8c63f72f22b7d3b0ebc865dc1b4cd9309c5b90 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 13 May 2026 15:05:54 +0100 Subject: [PATCH 14/18] Update CsvUtilTest.java --- .../fact/data/api/utils/CsvUtilTest.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 index caff3f13..384c1b82 100644 --- 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 @@ -59,14 +59,15 @@ void shouldFlattenEnrichedAddressesAndFieldsOfLaw() { Map result = csvUtil.flattenCourtNode(root); String addressesStr = result.get("addresses").toString(); - assertThat(addressesStr).contains("Address: 10 High St"); - assertThat(addressesStr).contains("Town: London"); - assertThat(addressesStr).contains("Postcode: SW1 1AA"); - assertThat(addressesStr).contains("Areas of Law: Crime"); - assertThat(addressesStr).contains("Courts: Crown Court"); + 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").toString()).isEqualTo("Crown Court"); + assertThat(result.get("types")).hasToString("Crown Court"); } @Test @@ -82,7 +83,7 @@ void shouldFlattenTypesFromCounterServiceIfAddressesEmpty() { Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("types").toString()).isEqualTo("Magistrates' Court"); + assertThat(result.get("types")).hasToString("Magistrates' Court"); } @Test @@ -98,7 +99,7 @@ void shouldHandleOpenOnCath() { @Test void shouldReturnNotAvailableIfNoNodesProvided() { Map result = csvUtil.flattenCourtNode(mapper.createObjectNode()); - assertThat(result.get("addresses").toString()).isEqualTo("No address available"); + assertThat(result.get("addresses")).hasToString("No address available"); } @Test @@ -113,7 +114,7 @@ void shouldFlattenTypesFromAddresses() { Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("types").toString()).isEqualTo("County Court | Tribunal"); + assertThat(result.get("types")).hasToString("County Court | Tribunal"); } @Test @@ -126,7 +127,7 @@ void shouldFlattenTypesFromCounterService() { Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("types").toString()).isEqualTo("Magistrates' Court"); + assertThat(result.get("types")).hasToString("Magistrates' Court"); } @Test @@ -142,7 +143,7 @@ void shouldAvoidDuplicateTypesFromCounterService() { Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("types").toString()).isEqualTo("Magistrates' Court"); + assertThat(result.get("types")).hasToString("Magistrates' Court"); } @Test From 3d3f94a2d72f1a6c1ad0a2471e38432e69c4436b Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Fri, 15 May 2026 16:21:06 +0100 Subject: [PATCH 15/18] Address comments on PR --- infrastructure/storage-account.tf | 4 ++ ...ureBlobConfigurationTestConfiguration.java | 26 ++++++++- .../api/controllers/CsvControllerTest.java | 43 +++++++++----- .../api/config/AzureBlobConfiguration.java | 26 ++++++++- .../data/api/models/StringMultipartFile.java | 58 +++++++++---------- .../data/api/services/AzureBlobService.java | 52 +++++------------ .../data/api/services/CourtPhotoService.java | 5 +- .../fact/data/api/services/CsvService.java | 12 ++-- src/main/resources/application.yaml | 3 +- .../config/AzureBlobConfigurationTest.java | 15 ++++- .../api/services/AzureBlobServiceTest.java | 33 +++++------ .../api/services/CourtPhotoServiceTest.java | 14 +++-- .../data/api/services/CsvServiceTest.java | 15 +++-- 13 files changed, 178 insertions(+), 128 deletions(-) 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/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 index e713e53a..643b0e4c 100644 --- 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 @@ -9,12 +9,18 @@ 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.errorhandling.exceptions.AzureUploadException; -import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException; -import uk.gov.hmcts.reform.fact.data.api.services.CsvService; +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 static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; +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; @@ -29,24 +35,35 @@ class CsvControllerTest { @Autowired private MockMvc mvc; + @MockitoBean(name = "csvAzureBlobService") + private AzureBlobService azureBlobService; + + @MockitoBean + private CourtService courtService; + @MockitoBean - private CsvService csvService; + private CourtDetailsViewService courtDetailsViewService; + + @MockitoBean + private SlackClient slackClient; @Test @DisplayName("POST /csv/ returns 200 when CSV is created and uploaded successfully") void createAndUploadCsvReturns200() throws Exception { - doNothing().when(csvService).createAndUploadCsv(); + 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") + @DisplayName("POST /csv/ returns 502 when Azure upload fails (e.g. container missing)") void createAndUploadCsvReturns502OnAzureUploadException() throws Exception { - doThrow(new AzureUploadException("Failed to upload CSV file to Azure Blob Storage")) - .when(csvService) - .createAndUploadCsv(); + when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); + when(azureBlobService.uploadFile(anyString(), any())).thenThrow(new RuntimeException("Container not found")); mvc.perform(post("/csv/")) .andExpect(status().isBadGateway()) @@ -56,9 +73,7 @@ void createAndUploadCsvReturns502OnAzureUploadException() throws Exception { @Test @DisplayName("POST /csv/ returns 500 when CSV creation fails") void createAndUploadCsvReturns500OnCsvCreationException() throws Exception { - doThrow(new CsvCreationException("Failed to create CSV file")) - .when(csvService) - .createAndUploadCsv(); + when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("DB error")); mvc.perform(post("/csv/")) .andExpect(status().isInternalServerError()) 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/models/StringMultipartFile.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java index 68ed38a9..d27db0f7 100644 --- 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 @@ -1,61 +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 originalFilename; private final String contentType; - private final byte[] content; - - public StringMultipartFile(String name, String originalFileName, String contentType, String content) { + 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.originalFilename = originalFilename; this.contentType = contentType; - this.content = content.getBytes(); - } - - @Override - public String getName() { - return name; - } - - @Override - public String getOriginalFilename() { - return originalFileName; - } - - @Override - public String getContentType() { - return contentType; + this.bytes = content.getBytes(); } @Override public boolean isEmpty() { - return content.length == 0; + return bytes.length == 0; } @Override public long getSize() { - return content.length; - } - - @Override - public byte[] getBytes() { - return content; + return bytes.length; } @Override public InputStream getInputStream() { - return new ByteArrayInputStream(content); + return new ByteArrayInputStream(bytes); } @Override public void transferTo(java.io.File dest) throws IllegalStateException { - // Optionally, you can implement the logic to transfer the content to a file here. + // 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 56fc2bdf..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 @@ -2,80 +2,58 @@ 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 lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException; import java.io.IOException; @Slf4j -@Service public class AzureBlobService { private final BlobContainerClient blobContainerClient; - private final BlobServiceClient blobServiceClient; - public AzureBlobService(BlobContainerClient blobContainerClient, BlobServiceClient blobServiceClient) { + public AzureBlobService(BlobContainerClient blobContainerClient) { this.blobContainerClient = blobContainerClient; - this.blobServiceClient = blobServiceClient; } /** - * 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) { - try { - blobClient.upload(file.getInputStream(), file.getSize(), true); - - BlobHttpHeaders headers = new BlobHttpHeaders() - .setContentType(file.getContentType()); - - blobClient.setHttpHeaders(headers); + BlobClient blobClient = blobContainerClient.getBlobClient(blobName); + uploadToBlob(blobClient, file); - } catch (IOException e) { - throw new AzureUploadException("Could not upload provided file to Azure"); - } + log.info("Uploaded file {} to {}", file.getOriginalFilename(), blobClient.getBlobUrl()); return blobClient.getBlobUrl(); } - public void uploadFile(String containerName, String blobName, MultipartFile file) { - BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); - if (!containerClient.exists()) { - containerClient.create(); - log.info("Created Azure blob container {}", containerName); - } - BlobClient blobClient = containerClient.getBlobClient(blobName); + 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); - log.info("Uploaded file {} to {}", file.getOriginalFilename(), containerName); - } catch (IOException e) { throw new AzureUploadException("Could not upload provided file to Azure"); } } /** - * 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 95f12558..01d312fa 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 @@ -9,6 +9,8 @@ import java.util.UUID; +import org.springframework.beans.factory.annotation.Qualifier; + @Slf4j @Service public class CourtPhotoService { @@ -18,7 +20,8 @@ public class CourtPhotoService { private final AzureBlobService azureBlobService; public CourtPhotoService(CourtPhotoRepository courtPhotoRepository, - CourtService courtService, AzureBlobService azureBlobService) { + CourtService courtService, + @Qualifier("photoAzureBlobService") AzureBlobService azureBlobService) { this.courtPhotoRepository = courtPhotoRepository; this.courtService = courtService; this.azureBlobService = azureBlobService; 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 index ce506573..d9d7c400 100644 --- 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 @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient; @@ -17,6 +16,8 @@ import java.util.ArrayList; import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; + /** * Service for handling CSV file operations. */ @@ -27,7 +28,6 @@ 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 String csvContainerName; private final CourtService courtService; private final CourtDetailsViewService courtDetailsViewService; private final AzureBlobService azureBlobService; @@ -36,16 +36,14 @@ public class CsvService { public CsvService(CourtService courtService, CourtDetailsViewService courtDetailsViewService, - AzureBlobService azureBlobService, + @Qualifier("csvAzureBlobService") AzureBlobService azureBlobService, ObjectMapper objectMapper, - SlackClient slackClient, - @Value("${fact.data-api.csv-container-name:csv}") String csvContainerName) { + SlackClient slackClient) { this.courtService = courtService; this.courtDetailsViewService = courtDetailsViewService; this.azureBlobService = azureBlobService; this.objectMapper = objectMapper; this.slackClient = slackClient; - this.csvContainerName = csvContainerName; } public void createAndUploadCsv() { @@ -61,7 +59,7 @@ public void createAndUploadCsv() { public void uploadCsvToAzureBlob(List actions, StringMultipartFile stringMultipartFile) { try { azureBlobService - .uploadFile(csvContainerName, CSV_FILE_NAME, stringMultipartFile); + .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."); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a143d803..364ddfda 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/services/AzureBlobServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobServiceTest.java index c9e8c380..41edc8eb 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 @@ -4,6 +4,7 @@ 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; @@ -28,7 +29,8 @@ class AzureBlobServiceTest { private static final String IMAGE_ID = "test-image-id"; - private static final String CONTAINER_NAME = "csv"; + private static final String CSV_CONTAINER_NAME = "csv"; + private static final String PHOTOS_CONTAINER_NAME = "photos"; private static final String CONTENT_TYPE = "image/jpeg"; private static final String BLOB_URL = "https://example.com/blob/test-image-id"; @@ -47,12 +49,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); @@ -71,7 +77,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, () -> @@ -84,8 +89,6 @@ void uploadFileShouldThrowAzureUploadExceptionWhenReadingFails() throws IOExcept @Test void deleteBlobShouldDeleteBlobById() { - when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); - azureBlobService.deleteBlob(IMAGE_ID); verify(blobClient).delete(); @@ -96,15 +99,14 @@ void uploadFileWithContainerShouldUploadToProvidedContainer() throws IOException byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); InputStream inputStream = new ByteArrayInputStream(fileBytes); - when(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); - when(blobContainerClient.exists()).thenReturn(true); - 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); + when(blobClient.getBlobUrl()).thenReturn(BLOB_URL); - azureBlobService.uploadFile(CONTAINER_NAME, IMAGE_ID, multipartFile); + 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)); } @@ -114,29 +116,24 @@ void uploadFileWithContainerShouldCreateContainerWhenMissing() throws IOExceptio byte[] fileBytes = "dummy payload".getBytes(StandardCharsets.UTF_8); InputStream inputStream = new ByteArrayInputStream(fileBytes); - when(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); - when(blobContainerClient.exists()).thenReturn(false); - 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); + when(blobClient.getBlobUrl()).thenReturn(BLOB_URL); - azureBlobService.uploadFile(CONTAINER_NAME, IMAGE_ID, multipartFile); + String result = azureBlobService.uploadFile(IMAGE_ID, multipartFile); - verify(blobContainerClient).create(); + 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(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); - when(blobContainerClient.exists()).thenReturn(true); - when(blobContainerClient.getBlobClient(IMAGE_ID)).thenReturn(blobClient); when(multipartFile.getInputStream()).thenThrow(new IOException("Read failure")); assertThrows(AzureUploadException.class, () -> - azureBlobService.uploadFile(CONTAINER_NAME, IMAGE_ID, multipartFile) + 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/CourtPhotoServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java index c165e6fd..7a294867 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java @@ -23,6 +23,8 @@ @ExtendWith(MockitoExtension.class) class CourtPhotoServiceTest { + private static final String CONTAINER_NAME = "photos"; + @Mock private CourtPhotoRepository courtPhotoRepository; @@ -76,8 +78,10 @@ void setCourtPhotoShouldCreateNewWhenNoneExists() { when(courtService.getCourtById(courtId)).thenReturn(null); when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.empty()); - when(azureBlobService.uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn(uploadedLink); - when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(azureBlobService + .uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn(uploadedLink); + when(courtPhotoRepository + .save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); @@ -95,8 +99,10 @@ void setCourtPhotoShouldUpdateExistingPhoto() { when(courtService.getCourtById(courtId)).thenReturn(null); when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.of(existing)); - when(azureBlobService.uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn("new-link"); - when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(azureBlobService + .uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn("new-link"); + when(courtPhotoRepository + .save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); 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 index 0a401cea..9fcdf58e 100644 --- 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 @@ -26,7 +26,7 @@ @ExtendWith(MockitoExtension.class) class CsvServiceTest { - private static final String CSV_CONTAINER_NAME = "csv-container"; + private static final String CSV_CONTAINER_NAME = "csv"; private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; @Mock @@ -51,7 +51,7 @@ void uploadCsvToAzureBlobShouldUseConfiguredContainerName() { csvService.uploadCsvToAzureBlob(actions, csvFile); - verify(azureBlobService).uploadFile(CSV_CONTAINER_NAME, CSV_FILE_NAME, csvFile); + verify(azureBlobService).uploadFile(CSV_FILE_NAME, csvFile); assertThat(actions).isEmpty(); } @@ -63,7 +63,7 @@ void uploadCsvToAzureBlobShouldAppendActionAndThrowWhenUploadFails() { doThrow(new RuntimeException("azure failure")) .when(azureBlobService) - .uploadFile(CSV_CONTAINER_NAME, CSV_FILE_NAME, csvFile); + .uploadFile(CSV_FILE_NAME, csvFile); AzureUploadException exception = assertThrows(AzureUploadException.class, () -> csvService.uploadCsvToAzureBlob(actions, csvFile) @@ -80,8 +80,8 @@ void createAndUploadCsvShouldCreateAndUploadWithoutSlackMessageOnSuccess() { csvService.createAndUploadCsv(); - verify(azureBlobService).uploadFile(org.mockito.ArgumentMatchers.eq(CSV_CONTAINER_NAME), - org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + verify(azureBlobService) + .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); verify(slackClient, never()).sendSlackMessage(any()); } @@ -102,8 +102,7 @@ void createAndUploadCsvShouldSendSlackMessageAndThrowWhenUploadFails() { when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList()); doThrow(new RuntimeException("azure failure")) .when(azureBlobService) - .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_CONTAINER_NAME), - org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); + .uploadFile(org.mockito.ArgumentMatchers.eq(CSV_FILE_NAME), any(StringMultipartFile.class)); AzureUploadException exception = assertThrows(AzureUploadException.class, csvService::createAndUploadCsv); @@ -114,6 +113,6 @@ void createAndUploadCsvShouldSendSlackMessageAndThrowWhenUploadFails() { private CsvService buildService() { return new CsvService( - courtService, courtDetailsViewService, azureBlobService, objectMapper, slackClient, CSV_CONTAINER_NAME); + courtService, courtDetailsViewService, azureBlobService, objectMapper, slackClient); } } From 1c537af1e9670d87e7bc11284be7a5db911bbe10 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Wed, 20 May 2026 16:48:26 +0100 Subject: [PATCH 16/18] fix jackson dependency after merge --- .../fact/data/api/config/JacksonConfiguration.java | 14 ++++++++++++++ .../data/api/services/AzureBlobServiceTest.java | 2 -- .../fact/data/api/services/CsvServiceTest.java | 1 - 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java new file mode 100644 index 00000000..08c3ff02 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java @@ -0,0 +1,14 @@ +package uk.gov.hmcts.reform.fact.data.api.config; + +import com.fasterxml.jackson.datatype.hibernate7.Hibernate7Module; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfiguration { + + @Bean + public Hibernate7Module hibernate7Module() { + return new Hibernate7Module(); + } +} 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 41edc8eb..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 @@ -29,8 +29,6 @@ class AzureBlobServiceTest { private static final String IMAGE_ID = "test-image-id"; - private static final String CSV_CONTAINER_NAME = "csv"; - private static final String PHOTOS_CONTAINER_NAME = "photos"; private static final String CONTENT_TYPE = "image/jpeg"; private static final String BLOB_URL = "https://example.com/blob/test-image-id"; 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 index 9fcdf58e..fe1e0e8b 100644 --- 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 @@ -26,7 +26,6 @@ @ExtendWith(MockitoExtension.class) class CsvServiceTest { - private static final String CSV_CONTAINER_NAME = "csv"; private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv"; @Mock From ba5b9be6f4fd76d6b058cd4c0ae8f8d3220b5829 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Thu, 21 May 2026 12:58:22 +0100 Subject: [PATCH 17/18] Update jackson --- build.gradle | 3 +-- .../data/api/config/JacksonConfiguration.java | 14 -------------- .../fact/data/api/services/CsvService.java | 2 +- .../reform/fact/data/api/utils/CsvUtil.java | 17 +++++++++-------- .../fact/data/api/services/CsvServiceTest.java | 5 +++-- .../reform/fact/data/api/utils/CsvUtilTest.java | 16 +++++++++------- 6 files changed, 23 insertions(+), 34 deletions(-) delete mode 100644 src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java diff --git a/build.gradle b/build.gradle index 60b8350e..80789e18 100644 --- a/build.gradle +++ b/build.gradle @@ -195,8 +195,7 @@ dependencies { implementation group: 'com.azure.spring', name: 'spring-cloud-azure-starter-storage-blob', version: '7.2.0' - implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-hibernate7', version: '2.21.3' - implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-csv', version: '2.21.3' + 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/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java deleted file mode 100644 index 08c3ff02..00000000 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/JacksonConfiguration.java +++ /dev/null @@ -1,14 +0,0 @@ -package uk.gov.hmcts.reform.fact.data.api.config; - -import com.fasterxml.jackson.datatype.hibernate7.Hibernate7Module; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class JacksonConfiguration { - - @Bean - public Hibernate7Module hibernate7Module() { - return new Hibernate7Module(); - } -} 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 index d9d7c400..b0a96441 100644 --- 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 @@ -1,6 +1,6 @@ package uk.gov.hmcts.reform.fact.data.api.services; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; 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 index 935824bd..47e9ee3f 100644 --- 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 @@ -1,10 +1,10 @@ package uk.gov.hmcts.reform.fact.data.api.utils; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.dataformat.csv.CsvMapper; -import com.fasterxml.jackson.dataformat.csv.CsvSchema; +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; @@ -56,8 +56,9 @@ protected CsvUtil(CsvMapper csvMapper) { } public CsvUtil() { - this.csvMapper = new CsvMapper(); - this.csvMapper.enable(SerializationFeature.INDENT_OUTPUT); + this.csvMapper = CsvMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); } public String convertJsonToCsv(JsonNode jsonArrayNode) { @@ -70,7 +71,7 @@ public String convertJsonToCsv(JsonNode jsonArrayNode) { try { return csvMapper.writer(schema).writeValueAsString(flatList); - } catch (RuntimeException | JsonProcessingException ex) { + } catch (JacksonException ex) { throw new JsonConvertException("Failed to convert JSON to CSV: " + ex.getMessage()); } } 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 index fe1e0e8b..ef7fadec 100644 --- 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 @@ -1,6 +1,7 @@ package uk.gov.hmcts.reform.fact.data.api.services; -import com.fasterxml.jackson.databind.ObjectMapper; +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; @@ -40,7 +41,7 @@ class CsvServiceTest { @Mock private SlackClient slackClient; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = JsonMapper.builder().build(); @Test void uploadCsvToAzureBlobShouldUseConfiguredContainerName() { 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 index 384c1b82..4bdc3119 100644 --- 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 @@ -1,9 +1,11 @@ package uk.gov.hmcts.reform.fact.data.api.utils; -import com.fasterxml.jackson.dataformat.csv.CsvMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +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; @@ -17,7 +19,7 @@ class CsvUtilTest { - private final ObjectMapper mapper = new ObjectMapper(); + private final ObjectMapper mapper = JsonMapper.builder().build(); private final CsvUtil csvUtil = new CsvUtil(); @Test @@ -295,8 +297,8 @@ void shouldThrowJsonConvertExceptionWhenCsvWritingFails() throws Exception { CsvUtil utilWithMock = new CsvUtil(mockCsvMapper); when(mockCsvMapper.writer(org.mockito.ArgumentMatchers.any( - com.fasterxml.jackson.dataformat.csv.CsvSchema.class))) - .thenThrow(new RuntimeException("Mock failure")); + tools.jackson.dataformat.csv.CsvSchema.class))) + .thenThrow(new JacksonException("Mock failure") {}); ArrayNode root = mapper.createArrayNode(); root.addObject(); From aa731a5ebafbee8ddc099f15f89b99693568dd17 Mon Sep 17 00:00:00 2001 From: Rebecca <67150587+RebeccaHayleyPickles@users.noreply.github.com> Date: Thu, 21 May 2026 16:21:06 +0100 Subject: [PATCH 18/18] Update CsvUtilTest.java --- .../fact/data/api/utils/CsvUtilTest.java | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) 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 index 4bdc3119..05771e09 100644 --- 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 @@ -95,7 +95,7 @@ void shouldHandleOpenOnCath() { Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("open")).isEqualTo(true); + assertThat(result).containsEntry("open", true); } @Test @@ -165,8 +165,9 @@ void shouldFlattenFieldsOfLawWithAreasAndCourts() { Map result = csvUtil.flattenCourtNode(root); String addressesStr = result.get("addresses").toString(); - assertThat(addressesStr).contains("Areas of Law: Family"); - assertThat(addressesStr).contains("Courts: High Court"); + assertThat(addressesStr) + .contains("Areas of Law: Family") + .contains("Courts: High Court"); } @Test @@ -184,8 +185,9 @@ void shouldFlattenFieldsOfLawWithTextualAreasAndCourts() { Map result = csvUtil.flattenCourtNode(root); String addressesStr = result.get("addresses").toString(); - assertThat(addressesStr).contains("Areas of Law: Family"); - assertThat(addressesStr).contains("Courts: High Court"); + assertThat(addressesStr) + .contains("Areas of Law: Family") + .contains("Courts: High Court"); } @Test @@ -202,8 +204,9 @@ void shouldFallbackToAddressNodeWhenFieldsOfLawNodeMissing() { Map result = csvUtil.flattenCourtNode(root); String addressesStr = result.get("addresses").toString(); - assertThat(addressesStr).contains("Areas of Law: Crime"); - assertThat(addressesStr).contains("Courts: Crown Court"); + assertThat(addressesStr) + .contains("Areas of Law: Crime") + .contains("Courts: Crown Court"); } @Test @@ -217,8 +220,9 @@ void shouldConvertJsonToCsv() { 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"); - assertThat(csv).contains("test-court"); + assertThat(csv) + .contains("Test Court") + .contains("test-court"); } @Test @@ -246,14 +250,14 @@ void shouldHandleDxCodes() { root.put("dx_number", "DX 123"); Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("dx_number")).isEqualTo("DX 123"); + 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.get("dx_number")).isEqualTo("DX 456"); + assertThat(result).containsEntry("dx_number", "DX 456"); } @Test @@ -273,12 +277,12 @@ void shouldReadNumbersAndBooleansCorrectly() { address.put("lon", -0.1); Map result = csvUtil.flattenCourtNode(root); - assertThat(result.get("open")).isEqualTo(true); - assertThat(result.get("number")).isEqualTo(123); - assertThat(result.get("cci_code")).isEqualTo(456); - assertThat(result.get("magistrate_code")).isEqualTo(789); - assertThat(result.get("lat")).isEqualTo(51.5); - assertThat(result.get("lon")).isEqualTo(-0.1); + 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 @@ -288,7 +292,7 @@ void shouldHandleNullsInReadMethods() { assertThat(result.get("lat")).isNull(); assertThat(result.get("number")).isNull(); - assertThat(result.get("open")).isEqualTo(false); + assertThat(result).containsEntry("open", false); } @Test