From 0863093873f41030c0f13730d9ee865a12159237 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 13:12:23 +0100 Subject: [PATCH 1/8] ARCPOC-1559 - Optimise bulk officials update and collapse audit writes - Batch-load, batch-delete, and batch-save officials for bulk update. - Replace per-official bulk audit events with a single bulk officials audit record. - Spill oversized audit payloads into CLOB-backed data-audit fields. - Update focused unit and integration tests for the new bulk officials behaviour. --- ...ationEntryControllerBulkOfficialsTest.java | 100 +++++++++++++++++- .../audit/AppListEntryAuditOperation.java | 1 + .../audit/BulkUpdateOfficialsAudit.java | 89 ++++++++++++++++ .../service/ApplicationEntryServiceImpl.java | 92 +++++++++++----- .../audit/listener/DataAuditLogger.java | 44 ++++++-- .../appregister/common/entity/DataAudit.java | 3 + .../AppListEntryOfficialRepository.java | 14 +++ .../ApplicationEntryServiceImplTest.java | 33 +++--- .../audit/DataAuditLoggerTest.java | 35 ++++++ 9 files changed, 356 insertions(+), 55 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkOfficialsTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkOfficialsTest.java index dcd0e251e..4e09de991 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkOfficialsTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkOfficialsTest.java @@ -2,13 +2,23 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.nimbusds.jose.JOSEException; import io.restassured.response.Response; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.util.List; +import java.util.Optional; import java.util.UUID; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ProblemDetail; +import uk.gov.hmcts.appregister.applicationentry.audit.AppListEntryAuditOperation; import uk.gov.hmcts.appregister.applicationentry.exception.AppListEntryError; import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; +import uk.gov.hmcts.appregister.common.entity.DataAudit; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.entity.repository.DataAuditRepository; import uk.gov.hmcts.appregister.generated.model.BulkOfficialsUpdateDto; import uk.gov.hmcts.appregister.generated.model.EntryGetDetailDto; import uk.gov.hmcts.appregister.generated.model.Official; @@ -18,6 +28,8 @@ class ApplicationEntryControllerBulkOfficialsTest extends AbstractApplicationEntryCrudTest { + @Autowired private DataAuditRepository dataAuditRepository; + @Test void givenValidEntries_whenReplaceOfficials_thenOfficialsAreReplacedForEveryEntry() throws Exception { @@ -49,6 +61,81 @@ void givenValidEntries_whenReplaceOfficials_thenOfficialsAreReplacedForEveryEntr .containsExactlyElementsOf(replacementOfficials); } + @Test + void givenValidEntries_whenReplaceOfficials_thenBulkAuditRowIsPersisted() throws Exception { + TokenGenerator tokenGenerator = createAdminToken(); + EntryGetDetailDto firstEntry = + createEntry(List.of(official("Mr", "Original", "One", OfficialType.CLERK))); + EntryGetDetailDto secondEntry = + createEntry(List.of(official("Mrs", "Original", "Two", OfficialType.MAGISTRATE))); + + replaceOfficials( + tokenGenerator, + firstEntry.getListId(), + new BulkOfficialsUpdateDto() + .entryIds(List.of(firstEntry.getId(), secondEntry.getId())) + .officials( + List.of( + official( + "Ms", + "Ada", + "Bench", + OfficialType.MAGISTRATE), + official( + "Mr", + "Clive", + "Court", + OfficialType.CLERK)))) + .then() + .statusCode(204); + + var bulkAuditRow = awaitBulkOfficialsAuditRow(); + Assertions.assertEquals( + AppListEntryAuditOperation.BULK_UPDATE_OFFICIALS.getEventName(), + bulkAuditRow.getEventName()); + assertThat(bulkAuditRow.getOldValue()).contains(firstEntry.getId().toString(), "Original"); + assertThat(bulkAuditRow.getOldValue()).contains(secondEntry.getId().toString(), "Two"); + assertThat(bulkAuditRow.getNewValue()).contains("Ada", "Bench", "Clive", "Court"); + assertThat( + dataAuditRepository.findDataAuditForTableAndColumnAndOldValue( + TableNames.APPLCATION_LISTS_ENTRY_OFFICIAL, "surname", "One")) + .isEmpty(); + assertThat( + dataAuditRepository.findDataAuditForTableAndColumnAndNewValue( + TableNames.APPLCATION_LISTS_ENTRY_OFFICIAL, "surname", "Bench")) + .isEmpty(); + } + + private DataAudit awaitBulkOfficialsAuditRow() { + for (int attempt = 0; attempt < 10; attempt++) { + Optional auditRow = + dataAuditRepository.findAll().stream() + .filter( + audit -> + TableNames.APPLICATION_LISTS_ENTRY.equals( + audit.getTableName()) + && "bulk_officials_officials" + .equals(audit.getColumnName()) + && AppListEntryAuditOperation + .BULK_UPDATE_OFFICIALS + .getEventName() + .equals(audit.getEventName())) + .findFirst(); + if (auditRow.isPresent()) { + return auditRow.get(); + } + + try { + Thread.sleep(50); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + break; + } + } + + throw new AssertionError("Expected a bulk officials audit row with old and new values"); + } + @Test void givenMissingEntry_whenReplaceOfficials_thenReturns400AndDoesNotReplaceAnyOfficials() throws Exception { @@ -85,7 +172,8 @@ void givenMissingEntry_whenReplaceOfficials_thenReturns400AndDoesNotReplaceAnyOf } @Test - void givenMissingApplicationList_whenReplaceOfficials_thenReturns404() throws Exception { + void givenMissingApplicationList_whenReplaceOfficials_thenReturns404() + throws MalformedURLException, JOSEException { TokenGenerator tokenGenerator = createAdminToken(); UUID missingListId = UUID.randomUUID(); @@ -101,7 +189,8 @@ void givenMissingApplicationList_whenReplaceOfficials_thenReturns404() throws Ex } @Test - void givenClosedApplicationList_whenReplaceOfficials_thenReturns409() throws Exception { + void givenClosedApplicationList_whenReplaceOfficials_thenReturns409() + throws MalformedURLException, JOSEException { TokenGenerator tokenGenerator = createAdminToken(); UUID closedListId = getClosedApplicationListId(); @@ -117,7 +206,8 @@ void givenClosedApplicationList_whenReplaceOfficials_thenReturns409() throws Exc } @Test - void givenEmptyEntryIds_whenReplaceOfficials_thenReturns400() throws Exception { + void givenEmptyEntryIds_whenReplaceOfficials_thenReturns400() + throws MalformedURLException, JOSEException { TokenGenerator tokenGenerator = createAdminToken(); Response response = @@ -306,7 +396,7 @@ private EntryGetDetailDto createEntry(List officials) throws Exception private Response replaceOfficials( TokenGenerator tokenGenerator, UUID listId, BulkOfficialsUpdateDto dto) - throws Exception { + throws MalformedURLException, JOSEException { return restAssuredClient.executePostRequest( getLocalUrl(CREATE_ENTRY_CONTEXT + "/" + listId + "/entries/officials"), tokenGenerator.fetchTokenForRole(), @@ -314,7 +404,7 @@ private Response replaceOfficials( } private EntryGetDetailDto getEntry(TokenGenerator tokenGenerator, UUID listId, UUID entryId) - throws Exception { + throws URISyntaxException, MalformedURLException, JOSEException { Response response = restAssuredClient.executeGetRequest( getLocalUrl(CREATE_ENTRY_CONTEXT + "/" + listId + "/entries/" + entryId), diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java index 8b77b2df8..8f69630db 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java @@ -10,6 +10,7 @@ public enum AppListEntryAuditOperation implements AuditOperation { CREATE_APP_ENTRY_LIST("Create Entry Application List", CrudEnum.CREATE), BULK_IMPORT_APP_ENTRIES("Bulk Import Application List Entries", CrudEnum.CREATE), + BULK_UPDATE_OFFICIALS("Bulk Update Officials", CrudEnum.UPDATE), UPDATE_APP_ENTRY_LIST("Update Entry Application List", CrudEnum.UPDATE), UPDATE_CLOSED_APP_ENTRY_LIST("Update Closed Entry Application List", CrudEnum.UPDATE), CREATE_OFFICIAL_ENTRY("Create Official", CrudEnum.CREATE), diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java new file mode 100644 index 000000000..f10fcb23c --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java @@ -0,0 +1,89 @@ +package uk.gov.hmcts.appregister.applicationentry.audit; + +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.AppListEntryOfficial; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.generated.model.Official; + +public record BulkUpdateOfficialsAudit( + Long listId, UUID listUuid, List entryIds, int entryCount, String officials) + implements Auditable { + + private static final String TABLE_NAME = TableNames.APPLICATION_LISTS_ENTRY; + private static final String LIST_UUID_FIELD = "bulk_officials_list_uuid"; + private static final String ENTRY_IDS_FIELD = "bulk_officials_entry_ids"; + private static final String ENTRY_COUNT_FIELD = "bulk_officials_entry_count"; + private static final String OFFICIALS_FIELD = "bulk_officials_officials"; + + @Override + public Long getId() { + return listId; + } + + @Override + public List extractAuditData(CrudEnum crudEnum) { + return List.of( + new AuditableData(TABLE_NAME, LIST_UUID_FIELD, listUuid.toString()), + new AuditableData(TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + new AuditableData(TABLE_NAME, ENTRY_COUNT_FIELD, Integer.toString(entryCount)), + new AuditableData(TABLE_NAME, OFFICIALS_FIELD, officials)); + } + + public static String formatDeletedOfficials(List deletedOfficials) { + return deletedOfficials.stream() + .sorted( + Comparator.comparing( + (AppListEntryOfficial official) -> + official.getAppListEntry().getSequenceNumber()) + .thenComparing( + AppListEntryOfficial::getId, + Comparator.nullsLast(Long::compareTo))) + .map( + official -> + ("{\"entryId\":\"%s\",\"sequenceNumber\":%s,\"type\":\"%s\"," + + "\"title\":\"%s\",\"forename\":\"%s\"," + + "\"surname\":\"%s\"}") + .formatted( + official.getAppListEntry().getUuid(), + official.getAppListEntry().getSequenceNumber(), + official.getOfficialType(), + escape(official.getTitle()), + escape(official.getForename()), + escape(official.getSurname()))) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String formatReplacementOfficials(List replacementOfficials) { + return replacementOfficials.stream() + .map( + official -> + "{\"type\":\"%s\",\"title\":\"%s\",\"forename\":\"%s\",\"surname\":\"%s\"}" + .formatted( + official.getType(), + escape(official.getTitle()), + escape(official.getForename()), + escape(official.getSurname()))) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String formatEntryIds(List entryIds) { + return entryIds.stream() + .sorted() + .map("\"%s\""::formatted) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java index dc0a0516f..1867051ef 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java @@ -10,6 +10,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -26,6 +27,7 @@ import uk.gov.hmcts.appregister.applicationentry.audit.AppListEntryAuditOperation; import uk.gov.hmcts.appregister.applicationentry.audit.ApplicationListEntryMoveAudit; import uk.gov.hmcts.appregister.applicationentry.audit.ApplicationListEntryReadAudit; +import uk.gov.hmcts.appregister.applicationentry.audit.BulkUpdateOfficialsAudit; import uk.gov.hmcts.appregister.applicationentry.audit.model.DeleteAuditable; import uk.gov.hmcts.appregister.applicationentry.exception.AppListEntryError; import uk.gov.hmcts.appregister.applicationentry.mapper.ApplicationListEntryEntityMapper; @@ -585,10 +587,7 @@ private List orderEntriesBySelectedIds entry -> entry, (first, second) -> first)); - return selectedEntryIds.stream() - .map(entriesByUuid::get) - .filter(entry -> entry != null) - .toList(); + return selectedEntryIds.stream().map(entriesByUuid::get).filter(Objects::nonNull).toList(); } private List toEntryIds(List entries) { @@ -879,6 +878,8 @@ private static String appendNotes(String existingNotes, String additionalNotes) return existingNotes + " " + additionalNotes; } + @Override + @NestedAudit @Transactional public void replaceOfficials(UUID listId, BulkOfficialsUpdateDto bulkOfficialsUpdateDto) { var payload = new BulkUpdateOfficialsPayload(listId, bulkOfficialsUpdateDto); @@ -888,11 +889,63 @@ public void replaceOfficials(UUID listId, BulkOfficialsUpdateDto bulkOfficialsUp (req, success) -> { List entries = new ArrayList<>(success.getEntries()); entries.sort(Comparator.comparing(ApplicationListEntry::getSequenceNumber)); + List replacementOfficials = req.data().getOfficials(); + var applicationList = + entries.isEmpty() ? null : entries.getFirst().getApplicationList(); + + List entryUuids = + entries.stream().map(ApplicationListEntry::getUuid).toList(); + Map> existingOfficialsByEntryUuid = + appListEntryOfficialRepository + .findByAppListEntry_UuidIn(entryUuids) + .stream() + .collect( + Collectors.groupingBy( + official -> + official.getAppListEntry().getUuid())); + + List entryIds = + entries.stream().map(ApplicationListEntry::getId).toList(); + List officialsToCreate = + new ArrayList<>(entries.size() * replacementOfficials.size()); + List deletedOfficials = + existingOfficialsByEntryUuid.values().stream() + .flatMap(List::stream) + .toList(); + var deletedOfficialsAudit = + new BulkUpdateOfficialsAudit( + applicationList != null ? applicationList.getId() : null, + listId, + entryUuids, + entries.size(), + BulkUpdateOfficialsAudit.formatDeletedOfficials( + deletedOfficials)); + var replacementOfficialsAudit = + new BulkUpdateOfficialsAudit( + applicationList != null ? applicationList.getId() : null, + listId, + entryUuids, + entries.size(), + BulkUpdateOfficialsAudit.formatReplacementOfficials( + replacementOfficials)); for (ApplicationListEntry entry : entries) { - replaceOfficialsForEntry(entry, req.data().getOfficials()); + addOfficialsForEntry(officialsToCreate, entry, replacementOfficials); } + auditService.processAudit( + deletedOfficialsAudit, + AppListEntryAuditOperation.BULK_UPDATE_OFFICIALS, + ignored -> { + if (!entryIds.isEmpty()) { + appListEntryOfficialRepository.deleteAllForEntryIds(entryIds); + } + + appListEntryOfficialRepository.saveAll(officialsToCreate); + return Optional.of( + new AuditableResult<>(null, replacementOfficialsAudit)); + }); + log.info( "Completed bulk officials replacement for {} entries in list {}", entries.size(), @@ -1638,31 +1691,12 @@ private List updateOfficials( return officialList; } - private void replaceOfficialsForEntry( - ApplicationListEntry entry, List replacementOfficials) { - List existingOfficials = - appListEntryOfficialRepository.getOfficialByEntryUuid(entry.getUuid()); - - for (AppListEntryOfficial existingOfficial : existingOfficials) { - auditService.processAudit( - existingOfficial, - AppListEntryAuditOperation.DELETE_OFFICIAL_ENTRY, - req -> { - appListEntryOfficialRepository.delete(existingOfficial); - return Optional.empty(); - }); - } - + private void addOfficialsForEntry( + List officialsToCreate, + ApplicationListEntry entry, + List replacementOfficials) { for (Official official : replacementOfficials) { - auditService.processAudit( - AppListEntryAuditOperation.CREATE_OFFICIAL_ENTRY, - req -> { - AppListEntryOfficial createdOfficial = - appListEntryOfficialRepository.save( - applicationListEntryEntityMapper.toOfficial( - official, entry)); - return Optional.of(new AuditableResult<>(null, createdOfficial)); - }); + officialsToCreate.add(applicationListEntryEntityMapper.toOfficial(official, entry)); } } diff --git a/src/main/java/uk/gov/hmcts/appregister/audit/listener/DataAuditLogger.java b/src/main/java/uk/gov/hmcts/appregister/audit/listener/DataAuditLogger.java index 3b1e2069d..1c704fe4e 100644 --- a/src/main/java/uk/gov/hmcts/appregister/audit/listener/DataAuditLogger.java +++ b/src/main/java/uk/gov/hmcts/appregister/audit/listener/DataAuditLogger.java @@ -34,6 +34,8 @@ public class DataAuditLogger extends AuditOperationLifecycleListenerAdapter { /** Represents a null value. We default to a null string. */ public static final String EMPTY_VALUE = ""; + private static final int AUDIT_VARCHAR_LIMIT = 4000; + @Value("${spring.jpa.properties.hibernate.default_schema}") private String schemaName; @@ -256,25 +258,51 @@ private static void setNewAndOldAuditValues( Long newRelatedKey) { if (primaryOld && secondaryDiff == null) { audit.setRelatedKey(oldRelatedKey); - audit.setNewValue(EMPTY_VALUE); - audit.setOldValue(primaryDiff.getValue()); + setNewAuditValue(audit, EMPTY_VALUE); + setOldAuditValue(audit, primaryDiff.getValue()); } else if (!primaryOld && secondaryDiff == null) { audit.setRelatedKey(newRelatedKey); - audit.setNewValue(primaryDiff.getValue()); - audit.setOldValue(EMPTY_VALUE); + setNewAuditValue(audit, primaryDiff.getValue()); + setOldAuditValue(audit, EMPTY_VALUE); } else { audit.setRelatedKey(newRelatedKey); if (primaryOld) { - audit.setOldValue(primaryDiff.getValue()); - audit.setNewValue(secondaryDiff.getValue()); + setOldAuditValue(audit, primaryDiff.getValue()); + setNewAuditValue(audit, secondaryDiff.getValue()); } else { - audit.setOldValue(secondaryDiff.getValue()); - audit.setNewValue(primaryDiff.getValue()); + setOldAuditValue(audit, secondaryDiff.getValue()); + setNewAuditValue(audit, primaryDiff.getValue()); } } } + private static void setOldAuditValue(DataAudit audit, String value) { + if (shouldUseClob(value)) { + audit.setOldValue(null); + audit.setOldClobValue(value); + return; + } + + audit.setOldValue(value); + audit.setOldClobValue(null); + } + + private static void setNewAuditValue(DataAudit audit, String value) { + if (shouldUseClob(value)) { + audit.setNewValue(null); + audit.setNewClobValue(value); + return; + } + + audit.setNewValue(value); + audit.setNewClobValue(null); + } + + private static boolean shouldUseClob(String value) { + return value != null && value.length() > AUDIT_VARCHAR_LIMIT; + } + private static void logAuditValues( AuditableData primaryDiff, AuditableData secondaryDiff, boolean primaryOld) { if (primaryOld && secondaryDiff == null) { diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/DataAudit.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/DataAudit.java index df1c89b92..fd30b7858 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/DataAudit.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/DataAudit.java @@ -83,6 +83,9 @@ public class DataAudit implements Changeable, Accountable { @Column(name = "old_clob_value") private String oldClobValue; + @Column(name = "new_clob_value") + private String newClobValue; + @Column(name = "related_key") private Long relatedKey; diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryOfficialRepository.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryOfficialRepository.java index 62a5a3b8d..dc69c1721 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryOfficialRepository.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryOfficialRepository.java @@ -43,4 +43,18 @@ public interface AppListEntryOfficialRepository extends JpaRepository entryIds); } diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java index c317c9a24..1388cc968 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java @@ -53,6 +53,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; +import uk.gov.hmcts.appregister.applicationentry.audit.AppListEntryAuditOperation; import uk.gov.hmcts.appregister.applicationentry.exception.AppListEntryError; import uk.gov.hmcts.appregister.applicationentry.mapper.ApplicationListEntryEntityMapper; import uk.gov.hmcts.appregister.applicationentry.mapper.ApplicationListEntryEntityMapperImpl; @@ -447,8 +448,7 @@ void testSearchForGetSummary() { when(mockPage.getPageNumber()).thenReturn(1); Page page = - new PageImpl( - List.of(applicationListEntryGetSummaryProjection), mockPage, 1); + new PageImpl<>(List.of(applicationListEntryGetSummaryProjection), mockPage, 1L); when(applicationListEntryMapStructMapper.toStatus(entryGetFilterDto.getStatus())) .thenReturn(Status.OPEN); @@ -2082,10 +2082,8 @@ void replaceOfficials_replacesOfficialsForAllEntries() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry1, entry2)); - when(appListEntryOfficialRepository.getOfficialByEntryUuid(entryId1)) - .thenReturn(List.of(existingOfficial1)); - when(appListEntryOfficialRepository.getOfficialByEntryUuid(entryId2)) - .thenReturn(List.of(existingOfficial2)); + when(appListEntryOfficialRepository.findByAppListEntry_UuidIn(List.of(entryId2, entryId1))) + .thenReturn(List.of(existingOfficial1, existingOfficial2)); when(applicationListEntryEntityMapper.toOfficial(any(Official.class), any())) .thenAnswer( invocation -> { @@ -2093,12 +2091,22 @@ void replaceOfficials_replacesOfficialsForAllEntries() { entity.setAppListEntry(invocation.getArgument(1)); return entity; }); + when(appListEntryOfficialRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); service.replaceOfficials(listId, dto); - verify(appListEntryOfficialRepository).delete(existingOfficial1); - verify(appListEntryOfficialRepository).delete(existingOfficial2); - verify(appListEntryOfficialRepository, times(2)).save(any(AppListEntryOfficial.class)); + verify(auditOperationService) + .processAudit(any(), eq(AppListEntryAuditOperation.BULK_UPDATE_OFFICIALS), any()); + verify(auditOperationService, never()) + .processAudit(eq(AppListEntryAuditOperation.CREATE_OFFICIAL_ENTRY), any()); + verify(auditOperationService, never()) + .processAudit( + any(AppListEntryOfficial.class), + eq(AppListEntryAuditOperation.DELETE_OFFICIAL_ENTRY), + any()); + verify(appListEntryOfficialRepository).deleteAllForEntryIds(List.of(102L, 101L)); + verify(appListEntryOfficialRepository).saveAll(anyList()); } @Test @@ -2142,8 +2150,8 @@ void replaceOfficials_throwsBeforeWriting_whenSomeRequestedEntriesAreMissingFrom ApplicationListError.ENTRY_NOT_IN_SOURCE_LIST, appEx.getCode()); }); - verify(appListEntryOfficialRepository, never()).delete(any(AppListEntryOfficial.class)); - verify(appListEntryOfficialRepository, never()).save(any(AppListEntryOfficial.class)); + verify(appListEntryOfficialRepository, never()).deleteAllForEntryIds(anyList()); + verify(appListEntryOfficialRepository, never()).saveAll(anyList()); } @Test @@ -2513,8 +2521,7 @@ void givenBulkImportHasNotSucceeded_whenGetApplicationListEntriesByJobId_returns (BiFunction) invocation.getArgument(1); - val success = new JobSuccess(); - return validateFunction.apply(jobId, success); + return validateFunction.apply(jobId, new JobSuccess()); }); when(asyncJobAppListEntryRepository.findByAsyncJobId(jobId)).thenReturn(List.of()); diff --git a/src/test/java/uk/gov/hmcts/appregister/audit/DataAuditLoggerTest.java b/src/test/java/uk/gov/hmcts/appregister/audit/DataAuditLoggerTest.java index fda47ea4a..f29a04ce9 100644 --- a/src/test/java/uk/gov/hmcts/appregister/audit/DataAuditLoggerTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/audit/DataAuditLoggerTest.java @@ -261,6 +261,41 @@ void testCreateAuditStillPersistsEmptyStringNewValue() { CrudEnum.CREATE, auditListCaptor.getValue().getFirst().getUpdateType()); } + @Test + void testUpdateAuditUsesClobColumnsWhenValuesExceedLimit() { + val oldValue = "a".repeat(4001); + val newValue = "b".repeat(4001); + var oldEntity = Mockito.mock(Keyable.class); + var newEntity = Mockito.mock(Keyable.class); + when(oldEntity.getId()).thenReturn(123L); + when(newEntity.getId()).thenReturn(123L); + + StartEvent startEvent = + new StartEvent(AppListAuditOperation.UPDATE_APP_LIST, "ID", oldEntity); + CompleteEvent auditRequest = new CompleteEvent(startEvent, null, newEntity); + + when(auditDifferentiator.extractAuditData(CrudEnum.UPDATE, oldEntity)) + .thenReturn( + List.of( + new AuditableData( + TableNames.APPLICATION_LISTS, "field", oldValue))); + when(auditDifferentiator.extractAuditData(CrudEnum.UPDATE, newEntity)) + .thenReturn( + List.of( + new AuditableData( + TableNames.APPLICATION_LISTS, "field", newValue))); + + new DataAuditLogger(auditDifferentiator, nestedAuditPersistenceManager) + .eventPerformed(auditRequest); + + verify(nestedAuditPersistenceManager).persistOrBuffer(auditListCaptor.capture()); + var audit = auditListCaptor.getValue().getFirst(); + Assertions.assertNull(audit.getOldValue()); + Assertions.assertNull(audit.getNewValue()); + Assertions.assertEquals(oldValue, audit.getOldClobValue()); + Assertions.assertEquals(newValue, audit.getNewClobValue()); + } + @Test void testAuditSaveFailureDoesNotEscapeOnCompleteEvent() { val testData = new ApplicationCodeTestData(); From a94f2754a3a709ab83bfe0e172a16ce08e910c09 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 14:39:39 +0100 Subject: [PATCH 2/8] ARCPOC-1561 - Optimise bulk application entry results creation - Batch bulk result validation and reuse resolved list, entry and result-code state. - Preserve bulk endpoint error semantics for missing entries versus wrong-list entries. - Keep bulk result persistence and auditing on the optimised batched path. - Add focused validator and controller test coverage for the batched behaviour. --- build.gradle | 2 +- ...cationEntryResultControllerCreateTest.java | 312 ++++-------------- .../AppListEntryResultAuditOperation.java | 1 + ...BulkCreateApplicationEntryResultAudit.java | 119 +++++++ .../ApplicationEntryResultServiceImpl.java | 85 ++++- ...ApplicationEntryResultCreationSuccess.java | 4 +- ...plicationEntryResultCreationValidator.java | 190 ++++++----- ...lkApplicationEntryResultValidatedItem.java | 8 + .../ApplicationListEntryRepository.java | 17 + ...ApplicationEntryResultServiceImplTest.java | 54 ++- ...ationEntryResultCreationValidatorTest.java | 189 +++++++---- 11 files changed, 581 insertions(+), 400 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultValidatedItem.java diff --git a/build.gradle b/build.gradle index 421d833f2..bd95d2c56 100644 --- a/build.gradle +++ b/build.gradle @@ -242,7 +242,7 @@ tasks.register('bootRunLocalDev', BootRun) { def activeProfiles = providers.gradleProperty('springProfiles') .orElse(providers.systemProperty('spring.profiles.active')) - .orElse('nosecurity') + .orElse('nosecurity,p6spy') systemProperty 'spring.profiles.active', activeProfiles.get() systemProperty 'logging.level.root', 'INFO' diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerCreateTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerCreateTest.java index 08f577893..78298aafd 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerCreateTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerCreateTest.java @@ -21,6 +21,7 @@ import uk.gov.hmcts.appregister.applicationentryresult.exception.ApplicationListEntryResultError; import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.DataAudit; import uk.gov.hmcts.appregister.common.entity.TableNames; import uk.gov.hmcts.appregister.common.exception.CommonAppError; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; @@ -369,6 +370,7 @@ void givenAValidBulkResultRequest_whenACallIsMadeWithAListAndTwoEntries_thenSucc new TemplateSubstitution("Date", "Date"), new TemplateSubstitution("Courthouse", "ch"))); bulkResultDto.setResult(createDto); + dataAuditRepository.deleteAll(); val token = getToken(); @@ -462,127 +464,21 @@ void givenAValidBulkResultRequest_whenACallIsMadeWithAListAndTwoEntries_thenSucc new AssertionError( "Created AppListEntryResolution could not be reloaded")); - // The resolution row itself should record its generated identifier on create. - val resultIdAuditRow = - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "aler_id", - createdResolution.getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.aler_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resultIdAuditRow.getEventName()); - - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "aler_id", - createdResolution1.getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.aler_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resultIdAuditRow.getEventName()); - - // The owning entry id is stored through the foreign key column ale_ale_id. - val entryIdAuditRow = - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "ale_ale_id", - createdResolution.getApplicationList().getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.ale_ale_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - entryIdAuditRow.getEventName()); - - // The owning entry id is stored through the foreign key column ale_ale_id. - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "ale_ale_id", - createdResolution1.getApplicationList().getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.ale_ale_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - entryIdAuditRow.getEventName()); - - // The selected resolution code should be recorded via the rc_rc_id join column. - val resolutionCodeAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "rc_rc_id", - createdResolution.getResolutionCode().getId().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow.get(1).getEventName()); - - val resolutionCodeAuditRow1 = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "rc_rc_id", - createdResolution1.getResolutionCode().getId().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow1.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow1.get(1).getEventName()); - - // The substituted wording is stored directly on the resolution row. - val wordingAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "al_entry_resolution_wording", - createdResolution.getResolutionWording()); - - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - wordingAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - wordingAuditRow.get(1).getEventName()); - - // The service stamps the acting user into the officer column on create. - val officerAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "al_entry_resolution_officer", - "email"); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - officerAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - officerAuditRow.get(1).getEventName()); - - // Version is database-backed and should be written alongside the other create audit rows. - val versionAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "version", - createdResolution.getVersion().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - versionAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - versionAuditRow.get(1).getEventName()); + val bulkAuditRow = + awaitBulkResultAuditRow( + entry.getUuid(), + entry2.getUuid(), + createdResolution.getUuid(), + createdResolution1.getUuid()); + Assertions.assertEquals( + AppListEntryResultAuditOperation.BULK_CREATE_APP_LIST_ENTRY_RESULT.getEventName(), + bulkAuditRow.getEventName()); + Assertions.assertTrue( + valueOrClob(bulkAuditRow.getNewValue(), bulkAuditRow.getNewClobValue()) + .contains(createdResolution.getResolutionWording())); + Assertions.assertTrue( + noPerResultCreateAuditRows(), + "Expected the bulk path to avoid per-result create audit rows"); } @Test @@ -604,6 +500,7 @@ void givenAValidBulkResultRequest_whenACallIsMadeWithAListAndTwoEntries_thenSucc new TemplateSubstitution("Date", "Date"), new TemplateSubstitution("Courthouse", "ch"))); bulkResultDto.setResult(createDto); + dataAuditRepository.deleteAll(); Response resp = createBulkResult(list.getUuid(), getToken(), bulkResultDto); @@ -861,127 +758,64 @@ void givenAValidBulkResultRequest_whenACallIsMadeWithAListAndTwoEntries_thenSucc new AssertionError( "Created AppListEntryResolution could not be reloaded")); - // The resolution row itself should record its generated identifier on create. - val resultIdAuditRow = - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "aler_id", - createdResolution.getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.aler_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resultIdAuditRow.getEventName()); - - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "aler_id", - createdResolution1.getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.aler_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resultIdAuditRow.getEventName()); - - // The owning entry id is stored through the foreign key column ale_ale_id. - val entryIdAuditRow = - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "ale_ale_id", - createdResolution.getApplicationList().getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.ale_ale_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - entryIdAuditRow.getEventName()); - - // The owning entry id is stored through the foreign key column ale_ale_id. - dataAuditRepository - .findDataAuditForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "ale_ale_id", - createdResolution1.getApplicationList().getId().toString()) - .orElseThrow( - () -> - new AssertionError( - "Expected an app_list_entry_resolutions.ale_ale_id create audit row")); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - entryIdAuditRow.getEventName()); - - // The selected resolution code should be recorded via the rc_rc_id join column. - val resolutionCodeAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "rc_rc_id", - createdResolution.getResolutionCode().getId().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow.get(1).getEventName()); - - val resolutionCodeAuditRow1 = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "rc_rc_id", - createdResolution1.getResolutionCode().getId().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow1.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - resolutionCodeAuditRow1.get(1).getEventName()); - - // The substituted wording is stored directly on the resolution row. - val wordingAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "al_entry_resolution_wording", - createdResolution.getResolutionWording()); + val bulkAuditRow = + awaitBulkResultAuditRow( + entry.getUuid(), + entry2.getUuid(), + createdResolution.getUuid(), + createdResolution1.getUuid()); + Assertions.assertEquals( + AppListEntryResultAuditOperation.BULK_CREATE_APP_LIST_ENTRY_RESULT.getEventName(), + bulkAuditRow.getEventName()); + Assertions.assertTrue( + valueOrClob(bulkAuditRow.getNewValue(), bulkAuditRow.getNewClobValue()) + .contains(createdResolution.getResolutionWording())); + Assertions.assertTrue( + noPerResultCreateAuditRows(), + "Expected the bulk path to avoid per-result create audit rows"); + } - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - wordingAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - wordingAuditRow.get(1).getEventName()); + private DataAudit awaitBulkResultAuditRow( + UUID entryId, UUID entryId1, UUID resultId, UUID resultId1) { + for (int attempt = 0; attempt < 20; attempt++) { + for (var auditRow : dataAuditRepository.findAll()) { + if (!AppListEntryResultAuditOperation.BULK_CREATE_APP_LIST_ENTRY_RESULT + .getEventName() + .equals(auditRow.getEventName())) { + continue; + } + + if (!TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS.equals(auditRow.getTableName()) + || !"bulk_results_created".equals(auditRow.getColumnName())) { + continue; + } + + var auditValue = valueOrClob(auditRow.getNewValue(), auditRow.getNewClobValue()); + if (auditValue.contains(entryId.toString()) + && auditValue.contains(entryId1.toString()) + && auditValue.contains(resultId.toString()) + && auditValue.contains(resultId1.toString())) { + return auditRow; + } + } + } + + throw new AssertionError("Expected a bulk result audit row with created result details"); + } - // The service stamps the acting user into the officer column on create. - val officerAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "al_entry_resolution_officer", - "email"); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - officerAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - officerAuditRow.get(1).getEventName()); + private boolean noPerResultCreateAuditRows() { + return dataAuditRepository.findAll().stream() + .noneMatch( + auditRow -> + AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT + .getEventName() + .equals(auditRow.getEventName()) + && TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS.equals( + auditRow.getTableName())); + } - // Version is database-backed and should be written alongside the other create audit rows. - val versionAuditRow = - dataAuditRepository.findDataAuditListForTableAndColumnAndNewValue( - TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS, - "version", - createdResolution.getVersion().toString()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - versionAuditRow.get(0).getEventName()); - Assertions.assertEquals( - AppListEntryResultAuditOperation.CREATE_APP_LIST_ENTRY_RESULT.getEventName(), - versionAuditRow.get(1).getEventName()); + private String valueOrClob(String value, String clobValue) { + return value != null ? value : clobValue; } @Test diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java index 8f8ac6e9f..685b0a484 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java @@ -10,6 +10,7 @@ public enum AppListEntryResultAuditOperation implements AuditOperation { DELETE_APP_LIST_ENTRY_RESULT("Delete Application List Entry Result", CrudEnum.DELETE), CREATE_APP_LIST_ENTRY_RESULT("Create Application List Entry Result", CrudEnum.CREATE), + BULK_CREATE_APP_LIST_ENTRY_RESULT("Result Multiple Applications", CrudEnum.CREATE), UPDATE_APP_LIST_ENTRY_RESULT("Update Application List Entry Result", CrudEnum.UPDATE), GET_APP_LIST_ENTRY_RESULT("Get Application List Entry Result", CrudEnum.READ); diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java new file mode 100644 index 000000000..d6ebcaa80 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java @@ -0,0 +1,119 @@ +package uk.gov.hmcts.appregister.applicationentryresult.audit; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.generated.model.TemplateSubstitution; + +public record BulkCreateApplicationEntryResultAudit( + Long anchorId, + UUID listUuid, + List entryIds, + int entryCount, + String resultCode, + String wordingFields, + String createdResults) + implements Auditable { + + private static final String TABLE_NAME = TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS; + private static final String LIST_UUID_FIELD = "bulk_results_list_uuid"; + private static final String ENTRY_IDS_FIELD = "bulk_results_entry_ids"; + private static final String ENTRY_COUNT_FIELD = "bulk_results_entry_count"; + private static final String RESULT_CODE_FIELD = "bulk_results_result_code"; + private static final String WORDING_FIELDS_FIELD = "bulk_results_wording_fields"; + private static final String CREATED_RESULTS_FIELD = "bulk_results_created"; + + @Override + public Long getId() { + return anchorId; + } + + @Override + public List extractAuditData(CrudEnum crudEnum) { + var auditData = + new ArrayList<>( + List.of( + new AuditableData( + TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + new AuditableData( + TABLE_NAME, + ENTRY_COUNT_FIELD, + Integer.toString(entryCount)), + new AuditableData(TABLE_NAME, RESULT_CODE_FIELD, resultCode), + new AuditableData(TABLE_NAME, WORDING_FIELDS_FIELD, wordingFields), + new AuditableData( + TABLE_NAME, CREATED_RESULTS_FIELD, createdResults))); + + if (listUuid != null) { + auditData.add(new AuditableData(TABLE_NAME, LIST_UUID_FIELD, listUuid.toString())); + } + + return auditData; + } + + public static String formatWordingFields(List wordingFields) { + if (wordingFields == null || wordingFields.isEmpty()) { + return "[]"; + } + + return wordingFields.stream() + .map( + substitution -> + "{\"key\":\"%s\",\"value\":\"%s\"}" + .formatted( + escape(substitution.getKey()), + escape(substitution.getValue()))) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String formatCreatedResults(List createdResults) { + return createdResults.stream() + .sorted( + Comparator.comparing( + (AppListEntryResolution resolution) -> + resolution.getApplicationList().getSequenceNumber()) + .thenComparing( + AppListEntryResolution::getId, + Comparator.nullsLast(Long::compareTo))) + .map( + resolution -> + """ + {"resultId":"%s","entryId":"%s","sequenceNumber":%s, + "resultCode":"%s","wording":"%s","officer":"%s"} + """ + .strip() + .formatted( + resolution.getUuid(), + resolution.getApplicationList().getUuid(), + resolution.getApplicationList().getSequenceNumber(), + escape( + resolution + .getResolutionCode() + .getResultCode()), + escape(resolution.getResolutionWording()), + escape(resolution.getResolutionOfficer()))) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String formatEntryIds(List entryIds) { + return entryIds.stream() + .sorted() + .map("\"%s\""::formatted) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java index 79a46fe77..35dff9098 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityManager; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -12,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import uk.gov.hmcts.appregister.applicationentryresult.audit.AppListEntryResultAuditOperation; import uk.gov.hmcts.appregister.applicationentryresult.audit.ApplicationListEntryResultAudit; +import uk.gov.hmcts.appregister.applicationentryresult.audit.BulkCreateApplicationEntryResultAudit; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultEntityMapper; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultMapper; import uk.gov.hmcts.appregister.applicationentryresult.model.ListEntryResultDeleteArgs; @@ -319,16 +321,83 @@ public List bulkCreate(PayloadForCreateResults bulk return bulkApplicationEntryResultCreationValidator.validate( bulkResultDto, (validate, success) -> { - List createdResults = new ArrayList<>(); - - // loop through each successful validation result and create the entry result - for (PayloadForCreateEntryResult createValidationSuccesses : - success.getResults()) { - createdResults.add( - getSelfService().create(createValidationSuccesses).getPayload()); + var resultPayload = validate.getPayload().getResult(); + var userEmail = userProvider.getEmail(); + var entitiesToCreate = + new ArrayList(success.getResults().size()); + + for (var validatedItem : success.getResults()) { + var payload = validatedItem.payload(); + var createSuccess = validatedItem.validationSuccess(); + var entity = + applicationListEntryResultEntityMapper.toApplicationListEntryResult( + payload.getData(), + createSuccess + .getWordingSentence() + .substitute(payload.getData().getWordingFields()) + .getSubstitutedString(), + createSuccess.getResolutionCode(), + createSuccess.getApplicationListEntry(), + userEmail); + entity.setResolutionOfficer(userEmail); + entitiesToCreate.add(entity); } - return createdResults; + return auditService.processAudit( + AppListEntryResultAuditOperation + .BULK_CREATE_APP_LIST_ENTRY_RESULT, + ignored -> { + repository.saveAll(entitiesToCreate); + entityManager.flush(); + + var reloadedEntities = + new ArrayList<>( + repository.findAllById( + entitiesToCreate.stream() + .map( + AppListEntryResolution + ::getId) + .toList())); + reloadedEntities.sort( + Comparator.comparing( + AppListEntryResolution::getId)); + + var bulkAudit = + new BulkCreateApplicationEntryResultAudit( + reloadedEntities.isEmpty() + ? null + : reloadedEntities + .getFirst() + .getId(), + validate.getListId(), + success.getResults().stream() + .map( + item -> + item.payload() + .getEntryId()) + .toList(), + reloadedEntities.size(), + resultPayload.getResultCode(), + BulkCreateApplicationEntryResultAudit + .formatWordingFields( + resultPayload + .getWordingFields()), + BulkCreateApplicationEntryResultAudit + .formatCreatedResults( + reloadedEntities)); + + var resultDtos = + reloadedEntities.stream() + .map( + applicationListEntryResultMapper + ::toResultGetDto) + .toList(); + + return Optional.of( + new AuditableResult<>(resultDtos, bulkAudit)); + }) + .stream() + .toList(); }); } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationSuccess.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationSuccess.java index 831254853..21b83829d 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationSuccess.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationSuccess.java @@ -4,11 +4,9 @@ import java.util.List; import lombok.Getter; import lombok.Setter; -import uk.gov.hmcts.appregister.applicationentryresult.model.PayloadForCreateEntryResult; -import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; @Getter @Setter public class BulkApplicationEntryResultCreationSuccess { - final List> results = new ArrayList<>(); + final List results = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidator.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidator.java index e7e539e12..f549a598a 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidator.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidator.java @@ -1,8 +1,10 @@ package uk.gov.hmcts.appregister.applicationentryresult.validator; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -15,10 +17,15 @@ import uk.gov.hmcts.appregister.applicationentryresult.model.PayloadForCreateResults; import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; import uk.gov.hmcts.appregister.common.entity.ApplicationList; -import uk.gov.hmcts.appregister.common.entity.model.EntryToList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.ResolutionCode; import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListEntryRepository; import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ResolutionCodeRepository; import uk.gov.hmcts.appregister.common.exception.AppRegistryException; +import uk.gov.hmcts.appregister.common.service.BusinessDateProvider; +import uk.gov.hmcts.appregister.common.template.wording.WordingTemplateSentence; +import uk.gov.hmcts.appregister.common.util.ReferenceDataSelectionUtil; import uk.gov.hmcts.appregister.common.validator.Validator; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; @@ -34,6 +41,10 @@ public class BulkApplicationEntryResultCreationValidator private final ApplicationListEntryRepository applicationListEntryRepository; + private final ResolutionCodeRepository resolutionCodeRepository; + + private final BusinessDateProvider businessDateProvider; + @Override public void validate(PayloadForCreateResults validatable) { validate(validatable, (v, r) -> null); @@ -48,77 +59,118 @@ public R validate( R> validateSuccess) { - Optional applicationListOptional; var entryIds = getEntryIds(validatable); - if (!entryIds.isEmpty()) { validateNoDuplicateEntryIds(entryIds); } - // lets cope with the situation where we may not have a list but if - // we do the entries should relate to those entries + ApplicationList validatedList = null; if (validatable.getListId() != null) { - log.debug("Validating bulk result entries for list {}", validatable.getListId()); - applicationListOptional = applicationListRepository.findByUuid(validatable.getListId()); - - if (applicationListOptional.isEmpty()) { - throw new AppRegistryException( - ApplicationListEntryResultError.APPLICATION_LIST_DOES_NOT_EXIST, - "The list does not exist %s".formatted(validatable.getListId())); - } - - validateEntryIdsProvided(entryIds); + validatedList = + applicationListRepository + .findByUuid(validatable.getListId()) + .orElseThrow( + () -> + new AppRegistryException( + ApplicationListEntryResultError + .APPLICATION_LIST_DOES_NOT_EXIST, + "The list does not exist %s" + .formatted(validatable.getListId()))); + validateParentApplicationListIsOpen(validatedList); + } - // now validate the entries belong to the list - boolean validated = - applicationListEntryRepository.doesApplicationEntryBelongToApplicationList( - entryIds, validatable.getListId()); + validateEntryIdsProvided(entryIds); - // if this is not valid then error - if (!validated) { + List entries; + if (validatable.getListId() != null) { + entries = + applicationListEntryRepository.findByUuidsInSourceList( + validatable.getListId(), new HashSet<>(entryIds)); + if (entries.size() < entryIds.size()) { + var activeEntries = applicationListEntryRepository.findActiveByUuids(entryIds); + if (activeEntries.size() < entryIds.size()) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_DOES_NOT_EXIST, + "One or more application list entries do not exist"); + } throw new AppRegistryException( ApplicationListEntryResultError .APPLICATION_ENTRY_RESULT_ENTRIES_NOT_IN_LIST, "The bulk result entries are not in the same application list for %s" .formatted(validatable.getListId())); } - } - - BulkApplicationEntryResultCreationSuccess bulkSuccess = - new BulkApplicationEntryResultCreationSuccess(); - - // process the validator according to whether we have the list ids or not - if (validatable.getListId() != null) { - List entryToListLst = getEntryForList(validatable.getListId(), entryIds); - - for (EntryToList entryToList : entryToListLst) { - // validate the entries - validateApplicationListEntryForResultCreation( - entryToList, validatable.getPayload(), bulkSuccess); - } } else { - // get all of the entry to list mappings - List entryToListMapping; - validateEntryIdsProvided(entryIds); - entryToListMapping = - applicationListEntryRepository.findApplicationListForAllEntries(entryIds); - - if (entryToListMapping.size() < entryIds.size()) { + entries = applicationListEntryRepository.findActiveByUuids(entryIds); + if (entries.size() < entryIds.size()) { throw new AppRegistryException( ApplicationListEntryResultError.APPLICATION_ENTRIES_NOT_ALL_EXIST, "The entries are not all present"); } - // now process all of the list id entries - for (EntryToList entryToList : entryToListMapping) { - log.debug( - "Validating bulk result entries acquiring list as not present {}", - entryToList.listId()); + entries.forEach( + entry -> validateParentApplicationListIsOpen(entry.getApplicationList())); + } + + ResolutionCode resolutionCode = null; + String wordingTemplate = null; + var createDto = + Optional.ofNullable(validatable.getPayload()) + .map(BulkResultDto::getResult) + .orElse(null); + if (createDto != null && createDto.getResultCode() != null) { + var todayUk = businessDateProvider.currentUkDate(); + var matchingCodes = + resolutionCodeRepository.findPrioritisingNullEndDate( + createDto.getResultCode(), todayUk); + if (matchingCodes.isEmpty()) { + throw new AppRegistryException( + ApplicationListEntryResultError.RESOLUTION_CODE_DOES_NOT_EXIST, + "No valid resolution code could be found %s" + .formatted(createDto.getResultCode())); + } + resolutionCode = + ReferenceDataSelectionUtil.selectFirstOrderedActiveRecord( + matchingCodes, + "result code", + createDto.getResultCode(), + todayUk, + ResolutionCode::getEndDate); + wordingTemplate = resolutionCode.getWording(); + } - validateApplicationListEntryForResultCreation( - entryToList, validatable.getPayload(), bulkSuccess); + BulkApplicationEntryResultCreationSuccess bulkSuccess = + new BulkApplicationEntryResultCreationSuccess(); + Map entriesByUuid = new HashMap<>(); + for (var entry : entries) { + entriesByUuid.put(entry.getUuid(), entry); + } + for (var entryId : entryIds) { + var entry = entriesByUuid.get(entryId); + if (entry == null) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_DOES_NOT_EXIST, + "No application list entry exists %s".formatted(entryId)); } + bulkSuccess + .getResults() + .add( + new BulkApplicationEntryResultValidatedItem( + PayloadForCreateEntryResult.builder() + .listId(entry.getApplicationList().getUuid()) + .entryId(entry.getUuid()) + .data(createDto) + .build(), + ListEntryResultCreateValidationSuccess.builder() + .applicationList(entry.getApplicationList()) + .applicationListEntry(entry) + .resolutionCode(resolutionCode) + .wordingSentence( + wordingTemplate == null + ? null + : WordingTemplateSentence.with( + wordingTemplate)) + .build())); } // now pass the success details through to the callback so the logic can take place @@ -149,40 +201,12 @@ private void validateEntryIdsProvided(List entryIds) { } } - private List getEntryForList(UUID listId, List entryIds) { - List entryToLists = new ArrayList<>(); - - for (UUID entryId : entryIds) { - entryToLists.add(new EntryToList(entryId, listId)); + private void validateParentApplicationListIsOpen(ApplicationList validatable) { + if (!validatable.isOpen()) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_LIST_STATE_IS_INCORRECT, + "The application list id %s is not in the correct state or the application list is deleted %s" + .formatted(validatable.getUuid(), validatable.getStatus())); } - - return entryToLists; - } - - /** - * validates the application list entry for result creation. - * - * @param entryToList The entry to list mapping - * @param bulkSuccess The success object to populate on each successful validation - */ - private void validateApplicationListEntryForResultCreation( - EntryToList entryToList, - BulkResultDto resultDto, - BulkApplicationEntryResultCreationSuccess bulkSuccess) { - - // now validate all associated details using the preexisting create validator - ResultCreateDto createDto = new ResultCreateDto(); - createDto.setResultCode(resultDto.getResult().getResultCode()); - createDto.setWordingFields(resultDto.getResult().getWordingFields()); - - // now execute the validation to ensure all resulted data is relevant - PayloadForCreateEntryResult result = - PayloadForCreateEntryResult.builder() - .listId(entryToList.listId()) - .entryId(entryToList.entryId()) - .data(createDto) - .build(); - - bulkSuccess.getResults().add(result); } } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultValidatedItem.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultValidatedItem.java new file mode 100644 index 000000000..ed5c18fc6 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultValidatedItem.java @@ -0,0 +1,8 @@ +package uk.gov.hmcts.appregister.applicationentryresult.validator; + +import uk.gov.hmcts.appregister.applicationentryresult.model.PayloadForCreateEntryResult; +import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; + +public record BulkApplicationEntryResultValidatedItem( + PayloadForCreateEntryResult payload, + ListEntryResultCreateValidationSuccess validationSuccess) {} diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java index 9074047e2..498bdcd80 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java @@ -772,6 +772,23 @@ ELSE FUNCTION('concat_ws', ' ', sa.applicantForename2, sa.applicantForename3) """) List findByUuidsInSourceList(UUID sourceListUuid, Set requestedIds); + /** + * Finds all non-deleted entries matching the supplied UUIDs, excluding rows whose parent lists + * are deleted. + * + * @param entryIds the entry UUIDs + * @return matching entries + */ + @Query( + """ + SELECT ale + FROM ApplicationListEntry ale + WHERE ale.uuid IN :entryIds + AND (ale.deleted IS NULL OR ale.deleted <> 'Y') + AND (ale.applicationList.deleted IS NULL OR ale.applicationList.deleted <> 'Y') + """) + List findActiveByUuids(List entryIds); + /** * Bulk-move entries to a new application list using a single JPQL UPDATE. Returns number of * rows updated. diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java index 51304f949..b671b2738 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java @@ -1,9 +1,11 @@ package uk.gov.hmcts.appregister.applicationentryresult.service; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static uk.gov.hmcts.appregister.common.template.wording.WordingTemplateSentence.with; @@ -29,6 +31,7 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import uk.gov.hmcts.appregister.applicationentryresult.audit.AppListEntryResultAuditOperation; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultEntityMapper; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultMapper; import uk.gov.hmcts.appregister.applicationentryresult.model.ListEntryResultDeleteArgs; @@ -42,6 +45,7 @@ import uk.gov.hmcts.appregister.applicationentryresult.validator.ApplicationEntryResultUpdateValidator; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultCreationSuccess; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultCreationValidator; +import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultValidatedItem; import uk.gov.hmcts.appregister.applicationentryresult.validator.ListEntryResultCreateValidationSuccess; import uk.gov.hmcts.appregister.applicationentryresult.validator.ListEntryResultDeleteValidationSuccess; import uk.gov.hmcts.appregister.applicationentryresult.validator.ListEntryResultGetValidationSuccess; @@ -131,7 +135,10 @@ class ApplicationEntryResultServiceImplTest { @Spy private DummyBulkApplicationEntryResultGetValidator bulkResultEntry = new DummyBulkApplicationEntryResultGetValidator( - applicationListRepository, applicationListEntryRepository); + applicationListRepository, + applicationListEntryRepository, + resolutionCodeRepository, + businessDateProvider); @Spy private final AuditOperationService auditOperationService = new DummyAuditOperationService(); @@ -200,6 +207,11 @@ void createAnEntryResult() { AppListEntryResolution entryToSave = new AppListEntryResolution(); entryToSave.setId(23232L); entryToSave.setVersion(2L); + entryToSave.setUuid(UUID.randomUUID()); + entryToSave.setApplicationList(applicationListEntry); + entryToSave.setResolutionCode(resolutionCode); + entryToSave.setResolutionWording("This is a template {My Substituted Value}"); + entryToSave.setResolutionOfficer("myemail@domain.com"); when(applicationListEntryResultEntityMapper.toApplicationListEntryResult( resultCreateDto, @@ -213,7 +225,8 @@ void createAnEntryResult() { new PayloadForCreateEntryResult<>( UUID.randomUUID(), UUID.randomUUID(), resultCreateDto); - when(appListEntryResolutionRepository.save(entryToSave)).thenReturn(entryToSave); + when(appListEntryResolutionRepository.save(any(AppListEntryResolution.class))) + .thenReturn(entryToSave); // setup the response of the call ResultGetDto resultGetDto = @@ -382,6 +395,11 @@ void bulkProcessEntries() { AppListEntryResolution entryToSave = new AppListEntryResolution(); entryToSave.setId(23232L); entryToSave.setVersion(2L); + entryToSave.setUuid(UUID.randomUUID()); + entryToSave.setApplicationList(applicationListEntry); + entryToSave.setResolutionCode(resolutionCode); + entryToSave.setResolutionWording("This is a template {My Substituted Value}"); + entryToSave.setResolutionOfficer("myemail@domain.com"); when(applicationListEntryResultEntityMapper.toApplicationListEntryResult( resultCreateDto, @@ -395,7 +413,10 @@ void bulkProcessEntries() { new PayloadForCreateEntryResult<>( UUID.randomUUID(), UUID.randomUUID(), resultCreateDto); - when(appListEntryResolutionRepository.save(entryToSave)).thenReturn(entryToSave); + when(appListEntryResolutionRepository.saveAll(List.of(entryToSave))) + .thenReturn(List.of(entryToSave)); + when(appListEntryResolutionRepository.findAllById(List.of(entryToSave.getId()))) + .thenReturn(List.of(entryToSave)); // setup the response of the call ResultGetDto resultGetDto = @@ -404,15 +425,28 @@ void bulkProcessEntries() { BulkApplicationEntryResultCreationSuccess bulkApplicationEntryResultCreationSuccess = new BulkApplicationEntryResultCreationSuccess(); - bulkApplicationEntryResultCreationSuccess.getResults().add(payload); + bulkApplicationEntryResultCreationSuccess + .getResults() + .add(new BulkApplicationEntryResultValidatedItem(payload, success)); bulkResultEntry.setSuccess(bulkApplicationEntryResultCreationSuccess); // make the call List createdResults = - service.bulkCreate(PayloadForCreateResults.builder().build()); + service.bulkCreate( + PayloadForCreateResults.builder() + .payload(new BulkResultDto().result(resultCreateDto)) + .build()); Assertions.assertEquals(List.of(resultGetDto), createdResults); + verify(appListEntryResolutionRepository).saveAll(List.of(entryToSave)); + verify(appListEntryResolutionRepository).findAllById(List.of(entryToSave.getId())); + verify(entityManager).flush(); + verify(auditOperationService) + .processAudit( + eq(AppListEntryResultAuditOperation.BULK_CREATE_APP_LIST_ENTRY_RESULT), + any()); + verify(appListEntryResolutionRepository, never()).save(any(AppListEntryResolution.class)); } @Setter @@ -562,9 +596,15 @@ static class DummyBulkApplicationEntryResultGetValidator public DummyBulkApplicationEntryResultGetValidator( ApplicationListRepository applicationListRepository, - ApplicationListEntryRepository applicationListEntryRepository) { + ApplicationListEntryRepository applicationListEntryRepository, + ResolutionCodeRepository resolutionCodeRepository, + BusinessDateProvider businessDateProvider) { - super(applicationListRepository, applicationListEntryRepository); + super( + applicationListRepository, + applicationListEntryRepository, + resolutionCodeRepository, + businessDateProvider); } @Override diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidatorTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidatorTest.java index 7754a1c1e..174358395 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultCreationValidatorTest.java @@ -4,7 +4,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; @@ -23,10 +22,13 @@ import uk.gov.hmcts.appregister.applicationentryresult.model.PayloadForCreateResults; import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; import uk.gov.hmcts.appregister.common.entity.ApplicationList; -import uk.gov.hmcts.appregister.common.entity.model.EntryToList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListEntryRepository; import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ResolutionCodeRepository; +import uk.gov.hmcts.appregister.common.enumeration.Status; import uk.gov.hmcts.appregister.common.exception.AppRegistryException; +import uk.gov.hmcts.appregister.common.service.BusinessDateProvider; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.TemplateSubstitution; @@ -38,6 +40,10 @@ class BulkApplicationEntryResultCreationValidatorTest { @Mock private ApplicationListEntryRepository applicationListEntryRepository; + @Mock private ResolutionCodeRepository resolutionCodeRepository; + + @Mock private BusinessDateProvider businessDateProvider; + @InjectMocks private BulkApplicationEntryResultCreationValidator validator; @Test @@ -45,28 +51,30 @@ void testSuccessWithListProvided() { UUID appList = UUID.randomUUID(); // setup 3 of the entries to belong to the list - UUID appEntry = UUID.randomUUID(); - UUID appEntry2 = UUID.randomUUID(); - UUID appEntry3 = UUID.randomUUID(); - - when(applicationListRepository.findByUuid(appList)) - .thenReturn(Optional.of(new ApplicationList())); + final UUID appEntry = UUID.randomUUID(); + final UUID appEntry2 = UUID.randomUUID(); + final UUID appEntry3 = UUID.randomUUID(); - OrderIndependantUuidMatcher uuidMatcher = - new OrderIndependantUuidMatcher(List.of(appEntry, appEntry2, appEntry3)); - when(applicationListEntryRepository.doesApplicationEntryBelongToApplicationList( - argThat(uuidMatcher), eq(appList))) - .thenReturn(true); + var list = new ApplicationList(); + list.setUuid(appList); + list.setStatus(Status.OPEN); + when(applicationListRepository.findByUuid(appList)).thenReturn(Optional.of(list)); ResultCreateDto resultCreateDto = new ResultCreateDto(); - resultCreateDto.setResultCode("code"); resultCreateDto.setWordingFields(List.of(new TemplateSubstitution())); BulkResultDto bulkResultDto = new BulkResultDto(); + bulkResultDto.setEntryIds(List.of(appEntry, appEntry2, appEntry3)); bulkResultDto.setResult(resultCreateDto); - bulkResultDto.setEntryIds(List.of(appEntry, appEntry2, appEntry3)); - bulkResultDto.setResult(new ResultCreateDto()); + when(applicationListEntryRepository.findByUuidsInSourceList( + eq(appList), + argThat(set -> set.containsAll(List.of(appEntry, appEntry2, appEntry3))))) + .thenReturn( + List.of( + entry(appEntry, list), + entry(appEntry2, list), + entry(appEntry3, list))); PayloadForCreateResults payloadForCreateEntryResult = PayloadForCreateResults.builder() @@ -80,20 +88,26 @@ void testSuccessWithListProvided() { payloadForCreateEntryResult, (v, r) -> { Assertions.assertEquals(3, r.getResults().size()); - Assertions.assertEquals(appList, r.getResults().get(0).getListId()); - Assertions.assertEquals(appList, r.getResults().get(1).getListId()); - Assertions.assertEquals(appList, r.getResults().get(2).getListId()); + Assertions.assertEquals( + appList, r.getResults().get(0).payload().getListId()); + Assertions.assertEquals( + appList, r.getResults().get(1).payload().getListId()); + Assertions.assertEquals( + appList, r.getResults().get(2).payload().getListId()); // ensure that the entries are one of the ones we expect Assertions.assertTrue( Set.of(appEntry, appEntry2, appEntry3) - .contains(r.getResults().get(0).getEntryId())); + .contains( + r.getResults().get(0).payload().getEntryId())); Assertions.assertTrue( Set.of(appEntry, appEntry2, appEntry3) - .contains(r.getResults().get(1).getEntryId())); + .contains( + r.getResults().get(1).payload().getEntryId())); Assertions.assertTrue( Set.of(appEntry, appEntry2, appEntry3) - .contains(r.getResults().get(2).getEntryId())); + .contains( + r.getResults().get(2).payload().getEntryId())); return true; }); @@ -106,36 +120,32 @@ void testSuccessWithNoListProvided() { UUID appList2 = UUID.randomUUID(); // setup 3 of the entries to belong to the list - UUID appEntry = UUID.randomUUID(); - UUID appEntry2 = UUID.randomUUID(); - UUID appEntry3 = UUID.randomUUID(); + final UUID appEntry = UUID.randomUUID(); + final UUID appEntry2 = UUID.randomUUID(); + final UUID appEntry3 = UUID.randomUUID(); - List entryToLists = new ArrayList<>(); - - // these are the entry to lists that we expect to be returned from the db - EntryToList entryToList = new EntryToList(appEntry, appList); - EntryToList entryToList2 = new EntryToList(appEntry2, appList2); - EntryToList entryToList3 = new EntryToList(appEntry3, appList2); - - entryToLists.add(entryToList); - entryToLists.add(entryToList2); - entryToLists.add(entryToList3); - - OrderIndependantUuidMatcher uuidMatcher = - new OrderIndependantUuidMatcher(List.of(appEntry, appEntry2, appEntry3)); - - when(applicationListEntryRepository.findApplicationListForAllEntries(argThat(uuidMatcher))) - .thenReturn(entryToLists); + var list = new ApplicationList(); + list.setUuid(appList); + list.setStatus(Status.OPEN); + var list2 = new ApplicationList(); + list2.setUuid(appList2); + list2.setStatus(Status.OPEN); ResultCreateDto resultCreateDto = new ResultCreateDto(); - resultCreateDto.setResultCode("code"); resultCreateDto.setWordingFields(List.of(new TemplateSubstitution())); BulkResultDto bulkResultDto = new BulkResultDto(); + bulkResultDto.setEntryIds(List.of(appEntry, appEntry2, appEntry3)); bulkResultDto.setResult(resultCreateDto); - bulkResultDto.setEntryIds(List.of(appEntry, appEntry2, appEntry3)); - bulkResultDto.setResult(new ResultCreateDto()); + OrderIndependantUuidMatcher uuidMatcher = + new OrderIndependantUuidMatcher(List.of(appEntry, appEntry2, appEntry3)); + when(applicationListEntryRepository.findActiveByUuids(argThat(uuidMatcher))) + .thenReturn( + List.of( + entry(appEntry, list), + entry(appEntry2, list2), + entry(appEntry3, list2))); PayloadForCreateResults payloadForCreateEntryResult = PayloadForCreateResults.builder().payload(bulkResultDto).build(); @@ -146,12 +156,18 @@ void testSuccessWithNoListProvided() { payloadForCreateEntryResult, (v, r) -> { Assertions.assertEquals(3, r.getResults().size()); - Assertions.assertEquals(appList, r.getResults().get(0).getListId()); - Assertions.assertEquals(appList2, r.getResults().get(1).getListId()); - Assertions.assertEquals(appList2, r.getResults().get(2).getListId()); - Assertions.assertEquals(appEntry, r.getResults().get(0).getEntryId()); - Assertions.assertEquals(appEntry2, r.getResults().get(1).getEntryId()); - Assertions.assertEquals(appEntry3, r.getResults().get(2).getEntryId()); + Assertions.assertEquals( + appList, r.getResults().get(0).payload().getListId()); + Assertions.assertEquals( + appList2, r.getResults().get(1).payload().getListId()); + Assertions.assertEquals( + appList2, r.getResults().get(2).payload().getListId()); + Assertions.assertEquals( + appEntry, r.getResults().get(0).payload().getEntryId()); + Assertions.assertEquals( + appEntry2, r.getResults().get(1).payload().getEntryId()); + Assertions.assertEquals( + appEntry3, r.getResults().get(2).payload().getEntryId()); return true; }); @@ -196,14 +212,17 @@ void testEntriesNotFoundForList() { payloadForCreateEntryResult.getPayload().setEntryIds(List.of(appEntry, appEntry2)); - when(applicationListRepository.findByUuid(appList)) - .thenReturn(Optional.of(new ApplicationList())); - + var list = new ApplicationList(); + list.setUuid(appList); + list.setStatus(Status.OPEN); + when(applicationListRepository.findByUuid(appList)).thenReturn(Optional.of(list)); + when(applicationListEntryRepository.findByUuidsInSourceList( + eq(appList), argThat(set -> set.containsAll(List.of(appEntry, appEntry2))))) + .thenReturn(List.of(entry(appEntry, list))); OrderIndependantUuidMatcher uuidMatcher = new OrderIndependantUuidMatcher(List.of(appEntry, appEntry2)); - when(applicationListEntryRepository.doesApplicationEntryBelongToApplicationList( - argThat(uuidMatcher), eq(appList))) - .thenReturn(false); + when(applicationListEntryRepository.findActiveByUuids(argThat(uuidMatcher))) + .thenReturn(List.of(entry(appEntry, list), entry(appEntry2, list))); // run the validation AppRegistryException exception = @@ -223,6 +242,49 @@ void testEntriesNotFoundForList() { exception.getCode().getCode()); } + @Test + void testMissingEntryForListReturnsEntryDoesNotExist() { + UUID appList = UUID.randomUUID(); + + var payloadForCreateEntryResult = + PayloadForCreateResults.builder() + .payload(new BulkResultDto()) + .listId(appList) + .build(); + + UUID appEntry = UUID.randomUUID(); + UUID missingEntry = UUID.randomUUID(); + + payloadForCreateEntryResult.getPayload().setEntryIds(List.of(appEntry, missingEntry)); + + var list = new ApplicationList(); + list.setUuid(appList); + list.setStatus(Status.OPEN); + when(applicationListRepository.findByUuid(appList)).thenReturn(Optional.of(list)); + when(applicationListEntryRepository.findByUuidsInSourceList( + eq(appList), + argThat(set -> set.containsAll(List.of(appEntry, missingEntry))))) + .thenReturn(List.of(entry(appEntry, list))); + OrderIndependantUuidMatcher uuidMatcher = + new OrderIndependantUuidMatcher(List.of(appEntry, missingEntry)); + when(applicationListEntryRepository.findActiveByUuids(argThat(uuidMatcher))) + .thenReturn(List.of(entry(appEntry, list))); + + var exception = + Assertions.assertThrows( + AppRegistryException.class, + () -> + validator.validate( + payloadForCreateEntryResult, + (v, r) -> { + return true; + })); + + Assertions.assertEquals( + ApplicationListEntryResultError.APPLICATION_ENTRY_DOES_NOT_EXIST.getCode(), + exception.getCode().getCode()); + } + @Test void testNoListFoundForAllEntries() { PayloadForCreateResults payloadForCreateEntryResult = @@ -237,9 +299,11 @@ void testNoListFoundForAllEntries() { OrderIndependantUuidMatcher uuidMatcher = new OrderIndependantUuidMatcher(List.of(appEntry, appEntry2)); - // return only one entry so we error - when(applicationListEntryRepository.findApplicationListForAllEntries(argThat(uuidMatcher))) - .thenReturn(List.of(new EntryToList(UUID.randomUUID(), UUID.randomUUID()))); + var list = new ApplicationList(); + list.setUuid(UUID.randomUUID()); + list.setStatus(Status.OPEN); + when(applicationListEntryRepository.findActiveByUuids(argThat(uuidMatcher))) + .thenReturn(List.of(entry(UUID.randomUUID(), list))); // run the validation AppRegistryException exception = @@ -327,4 +391,11 @@ public boolean matches(List uuid) { return true; } } + + private ApplicationListEntry entry(UUID entryId, ApplicationList list) { + var entry = new ApplicationListEntry(); + entry.setUuid(entryId); + entry.setApplicationList(list); + return entry; + } } From a49cf6f3457baaa0eb0639e4496b4ec913644954 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 16:08:16 +0100 Subject: [PATCH 3/8] ARCPOC-1559 - Optimise bulk application entry moves - Batch target-list resequencing for bulk entry moves. - Replace per-entry move auditing with a single bulk move audit payload. - Persist moved entries through the batched save path. - Update unit and integration tests for the bulk move audit and rollback behaviour. --- .../ApplicationEntryControllerMoveTest.java | 147 ++++++++---------- .../audit/AppListEntryAuditOperation.java | 1 + .../BulkMoveApplicationListEntriesAudit.java | 75 +++++++++ .../service/ApplicationEntryServiceImpl.java | 61 +++++--- ...lkMoveApplicationListEntriesAuditTest.java | 92 +++++++++++ .../ApplicationEntryServiceImplTest.java | 41 +++-- 6 files changed, 300 insertions(+), 117 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAudit.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAuditTest.java diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerMoveTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerMoveTest.java index 8f2d30487..ff8fd32ec 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerMoveTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerMoveTest.java @@ -119,14 +119,8 @@ void givenValidRequest_whenMove_thenDataAuditRowsPersisted() throws Exception { val sourceList = createOpenListWithEntry(sourceEntry); val targetList = createOpenTargetList(); - // Re-read the persisted entry so we can compare the version before and after the move. - val persistedBeforeMove = - applicationListEntryRepository.findByUuid(sourceEntry.getUuid()).orElseThrow(); - // Clear earlier audit rows so these assertions only inspect the move request. dataAuditRepository.deleteAll(); - - val originalVersion = persistedBeforeMove.getVersion(); val resp = moveEntries(sourceList, targetList, Set.of(sourceEntry.getUuid())); resp.then().statusCode(HttpStatus.OK.value()); @@ -134,56 +128,65 @@ void givenValidRequest_whenMove_thenDataAuditRowsPersisted() throws Exception { val persistedAfterMove = applicationListEntryRepository.findByUuid(sourceEntry.getUuid()).orElseThrow(); - // The entry should now belong to the target list and have an incremented version. + // The entry should now belong to the target list. Assertions.assertEquals( targetList.getId(), persistedAfterMove.getApplicationList().getId()); - Assertions.assertTrue(persistedAfterMove.getVersion() > originalVersion); - - // The move audit should capture the old and new list identifiers for the moved entry. - val listIdAuditRow = - dataAuditRepository.findAll().stream() - .filter(row -> TableNames.APPLICATION_LISTS.equals(row.getTableName())) - .filter(row -> "al_id".equals(row.getColumnName())) - .filter(row -> sourceList.getId().toString().equals(row.getOldValue())) - .filter(row -> targetList.getId().toString().equals(row.getNewValue())) - .findFirst() - .orElseThrow( - () -> - new AssertionError( - "Expected an application_lists.al_id move audit row")); - - Assertions.assertEquals( - AppListEntryAuditOperation.MOVE_APP_ENTRY.getEventName(), - listIdAuditRow.getEventName()); - Assertions.assertEquals( - AppListEntryAuditOperation.MOVE_APP_ENTRY.getType(), - listIdAuditRow.getUpdateType()); - // The same move request should also audit the version increment on the entry row itself. - val versionAuditRow = + // The bulk move audit should capture source and target list UUIDs plus the moved entry. + val moveAuditRows = dataAuditRepository.findAll().stream() .filter( + row -> + AppListEntryAuditOperation.BULK_MOVE_APP_ENTRIES + .getEventName() + .equals(row.getEventName())) + .toList(); + + Assertions.assertFalse(moveAuditRows.isEmpty()); + Assertions.assertTrue( + moveAuditRows.stream() + .anyMatch( row -> TableNames.APPLICATION_LISTS_ENTRY.equals( - row.getTableName())) - .filter(row -> "version".equals(row.getColumnName())) - .filter(row -> Long.toString(originalVersion).equals(row.getOldValue())) - .filter( + row.getTableName()) + && "bulk_move_source_list_uuid" + .equals(row.getColumnName()) + && sourceList + .getUuid() + .toString() + .equals(row.getOldValue()) + && sourceList + .getUuid() + .toString() + .equals(row.getNewValue()))); + Assertions.assertTrue( + moveAuditRows.stream() + .anyMatch( row -> - Long.toString(persistedAfterMove.getVersion()) - .equals(row.getNewValue())) - .findFirst() - .orElseThrow( - () -> - new AssertionError( - "Expected an application_list_entries.version move audit row")); - - Assertions.assertEquals( - AppListEntryAuditOperation.MOVE_APP_ENTRY.getEventName(), - versionAuditRow.getEventName()); - Assertions.assertEquals( - AppListEntryAuditOperation.MOVE_APP_ENTRY.getType(), - versionAuditRow.getUpdateType()); + TableNames.APPLICATION_LISTS_ENTRY.equals( + row.getTableName()) + && "bulk_move_target_list_uuid" + .equals(row.getColumnName()) + && targetList + .getUuid() + .toString() + .equals(row.getOldValue()) + && targetList + .getUuid() + .toString() + .equals(row.getNewValue()))); + Assertions.assertTrue( + moveAuditRows.stream() + .anyMatch( + row -> + TableNames.APPLICATION_LISTS_ENTRY.equals( + row.getTableName()) + && "bulk_move_entries".equals(row.getColumnName()) + && row.getOldValue() + .contains(sourceEntry.getUuid().toString()) + && row.getNewValue() + .contains( + sourceEntry.getUuid().toString()))); } @Test @@ -201,9 +204,8 @@ void givenSecondMoveSaveFails_whenMove_thenAllEntriesRollBack() throws Exception // Clear earlier audit rows so we only inspect what this failing move attempted to write. dataAuditRepository.deleteAll(); - // Fail on the second save call inside the move loop so we can prove the first move rolls - // back as part of the same transaction. - moveEntryFailureSwitch.failOnSecondSave(); + // Fail during the batched saveAll call so we can prove the move still rolls back. + moveEntryFailureSwitch.failOnMoveSave(); try { val resp = @@ -228,39 +230,17 @@ void givenSecondMoveSaveFails_whenMove_thenAllEntriesRollBack() throws Exception Assertions.assertEquals(sourceList.getId(), persistedFirst.getApplicationList().getId()); Assertions.assertEquals(sourceList.getId(), persistedSecond.getApplicationList().getId()); - // The business move rolls back, but the attempted move audit remains persisted in line - // with the legacy autonomous-audit behaviour. + // The business move rolls back and no completed bulk-move audit rows are persisted. val moveAuditRows = dataAuditRepository.findAll().stream() .filter( row -> - AppListEntryAuditOperation.MOVE_APP_ENTRY + AppListEntryAuditOperation.BULK_MOVE_APP_ENTRIES .getEventName() .equals(row.getEventName())) .toList(); - Assertions.assertFalse(moveAuditRows.isEmpty()); - Assertions.assertTrue( - moveAuditRows.stream() - .anyMatch( - row -> - TableNames.APPLICATION_LISTS.equals(row.getTableName()) - && "al_id".equals(row.getColumnName()) - && sourceList - .getId() - .toString() - .equals(row.getOldValue()) - && targetList - .getId() - .toString() - .equals(row.getNewValue()))); - Assertions.assertTrue( - moveAuditRows.stream() - .anyMatch( - row -> - TableNames.APPLICATION_LISTS_ENTRY.equals( - row.getTableName()) - && "version".equals(row.getColumnName()))); + Assertions.assertTrue(moveAuditRows.isEmpty()); } @Test @@ -591,10 +571,9 @@ public Object postProcessAfterInitialization(Object bean, String beanName) bean.getClass().getClassLoader(), bean.getClass().getInterfaces(), (proxy, method, args) -> { - if ("save".equals(method.getName()) - && args != null + if (args != null && args.length == 1 - && switcher.shouldFail(args[0])) { + && switcher.shouldFail(method.getName(), args[0])) { throw new IllegalStateException( "Simulated move save failure for rollback test"); } @@ -614,7 +593,7 @@ static class MoveEntryFailureSwitch { private final AtomicBoolean enabled = new AtomicBoolean(false); private final AtomicInteger saveCount = new AtomicInteger(0); - void failOnSecondSave() { + void failOnMoveSave() { saveCount.set(0); enabled.set(true); } @@ -624,10 +603,12 @@ void reset() { saveCount.set(0); } - boolean shouldFail(Object candidate) { + boolean shouldFail(String methodName, Object candidate) { return enabled.get() - && candidate instanceof ApplicationListEntry - && saveCount.incrementAndGet() == 2; + && (("saveAll".equals(methodName) && candidate instanceof Iterable) + || ("save".equals(methodName) + && candidate instanceof ApplicationListEntry)) + && saveCount.incrementAndGet() == 1; } } } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java index 8f69630db..67e34589a 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java @@ -10,6 +10,7 @@ public enum AppListEntryAuditOperation implements AuditOperation { CREATE_APP_ENTRY_LIST("Create Entry Application List", CrudEnum.CREATE), BULK_IMPORT_APP_ENTRIES("Bulk Import Application List Entries", CrudEnum.CREATE), + BULK_MOVE_APP_ENTRIES("Bulk Move Application List Entries", CrudEnum.UPDATE), BULK_UPDATE_OFFICIALS("Bulk Update Officials", CrudEnum.UPDATE), UPDATE_APP_ENTRY_LIST("Update Entry Application List", CrudEnum.UPDATE), UPDATE_CLOSED_APP_ENTRY_LIST("Update Closed Entry Application List", CrudEnum.UPDATE), diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAudit.java new file mode 100644 index 000000000..d1e7545b0 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAudit.java @@ -0,0 +1,75 @@ +package uk.gov.hmcts.appregister.applicationentry.audit; + +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; + +public record BulkMoveApplicationListEntriesAudit( + Long sourceListId, + UUID sourceListUuid, + Long targetListId, + UUID targetListUuid, + int entryCount, + String entries) + implements Auditable { + + private static final String TABLE_NAME = TableNames.APPLICATION_LISTS_ENTRY; + private static final String SOURCE_LIST_UUID_FIELD = "bulk_move_source_list_uuid"; + private static final String TARGET_LIST_UUID_FIELD = "bulk_move_target_list_uuid"; + private static final String ENTRY_COUNT_FIELD = "bulk_move_entry_count"; + private static final String ENTRIES_FIELD = "bulk_move_entries"; + + @Override + public Long getId() { + return sourceListId; + } + + @Override + public List extractAuditData(CrudEnum crudEnum) { + return List.of( + new AuditableData(TABLE_NAME, SOURCE_LIST_UUID_FIELD, sourceListUuid.toString()), + new AuditableData(TABLE_NAME, TARGET_LIST_UUID_FIELD, targetListUuid.toString()), + new AuditableData(TABLE_NAME, ENTRY_COUNT_FIELD, Integer.toString(entryCount)), + new AuditableData(TABLE_NAME, ENTRIES_FIELD, entries)); + } + + public static BulkMoveApplicationListEntriesAudit forState( + Long sourceListId, + UUID sourceListUuid, + Long targetListId, + UUID targetListUuid, + List entries) { + var sortedEntries = + entries.stream() + .sorted( + Comparator.comparing(ApplicationListEntry::getSequenceNumber) + .thenComparing(ApplicationListEntry::getId)) + .toList(); + + return new BulkMoveApplicationListEntriesAudit( + sourceListId, + sourceListUuid, + targetListId, + targetListUuid, + sortedEntries.size(), + formatEntries(sortedEntries)); + } + + private static String formatEntries(List entries) { + return entries.stream() + .map( + entry -> + "{\"entryId\":\"%s\",\"sequenceNumber\":%s,\"version\":%s}" + .formatted( + entry.getUuid(), + entry.getSequenceNumber(), + entry.getVersion())) + .collect(Collectors.joining(",", "[", "]")); + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java index b5f4e5a98..63d8c7b89 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java @@ -27,9 +27,9 @@ import org.springframework.transaction.annotation.Transactional; import uk.gov.hmcts.appregister.applicationentry.api.ApplicationEntrySortConfig; import uk.gov.hmcts.appregister.applicationentry.audit.AppListEntryAuditOperation; -import uk.gov.hmcts.appregister.applicationentry.audit.ApplicationListEntryMoveAudit; import uk.gov.hmcts.appregister.applicationentry.audit.ApplicationListEntryReadAudit; import uk.gov.hmcts.appregister.applicationentry.audit.BulkApplicationListEntriesReadAudit; +import uk.gov.hmcts.appregister.applicationentry.audit.BulkMoveApplicationListEntriesAudit; import uk.gov.hmcts.appregister.applicationentry.audit.BulkUpdateOfficialsAudit; import uk.gov.hmcts.appregister.applicationentry.audit.model.DeleteAuditable; import uk.gov.hmcts.appregister.applicationentry.exception.AppListEntryError; @@ -1998,24 +1998,41 @@ public void move(UUID sourceListId, MoveEntriesDto moveEntriesDto) { List orderedEntriesToMove = new ArrayList<>(entriesToMove); orderedEntriesToMove.sort(Comparator.comparing(ApplicationListEntry::getSequenceNumber)); + var oldAudit = + BulkMoveApplicationListEntriesAudit.forState( + orderedEntriesToMove.getFirst().getApplicationList().getId(), + sourceListId, + targetList.getId(), + targetList.getUuid(), + orderedEntriesToMove); - for (ApplicationListEntry entryToMove : orderedEntriesToMove) { - auditService.processAudit( - ApplicationListEntryMoveAudit.from(entryToMove), - AppListEntryAuditOperation.MOVE_APP_ENTRY, - req -> { + auditService.processAudit( + oldAudit, + AppListEntryAuditOperation.BULK_MOVE_APP_ENTRIES, + req -> { + short nextSequence = + allocateNextSequence(targetList.getId(), orderedEntriesToMove.size()); + short startingSequence = + (short) (nextSequence - orderedEntriesToMove.size() + 1); + + for (int i = 0; i < orderedEntriesToMove.size(); i++) { + var entryToMove = orderedEntriesToMove.get(i); entryToMove.setApplicationList(targetList); - entryToMove.setSequenceNumber(allocateNextSequence(targetList.getId())); + entryToMove.setSequenceNumber((short) (startingSequence + i)); + } - var movedEntry = - refreshEntity(applicationListEntryRepository.save(entryToMove)); + applicationListEntryRepository.saveAll(orderedEntriesToMove); - return Optional.of( - new AuditableResult<>( - movedEntry.getUuid(), - ApplicationListEntryMoveAudit.from(movedEntry))); - }); - } + var newAudit = + BulkMoveApplicationListEntriesAudit.forState( + oldAudit.sourceListId(), + sourceListId, + targetList.getId(), + targetList.getUuid(), + orderedEntriesToMove); + + return Optional.of(new AuditableResult<>(null, newAudit)); + }); log.info( "Completed bulk move for {} entries from list {}", @@ -2102,18 +2119,26 @@ private List getKeyablesForCreateUpdateEtag(ApplicationListEntry update } private short allocateNextSequence(Long alId) { + return allocateNextSequence(alId, 1); + } + + private short allocateNextSequence(Long alId, int requiredCount) { AppListEntrySequenceMapping mapping = appListEntrySequenceMappingRepository.findByAlIdForUpdate(alId).orElse(null); if (mapping == null) { - mapping = AppListEntrySequenceMapping.builder().alId(alId).aleLastSequence(1).build(); + mapping = + AppListEntrySequenceMapping.builder() + .alId(alId) + .aleLastSequence(requiredCount) + .build(); appListEntrySequenceMappingRepository.save(mapping); - return (short) 1; + return (short) requiredCount; } - int next = mapping.getAleLastSequence() + 1; + int next = mapping.getAleLastSequence() + requiredCount; mapping.setAleLastSequence(next); return (short) next; diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAuditTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAuditTest.java new file mode 100644 index 000000000..8293d36e9 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkMoveApplicationListEntriesAuditTest.java @@ -0,0 +1,92 @@ +package uk.gov.hmcts.appregister.applicationentry.audit; + +import java.util.List; +import java.util.UUID; +import lombok.val; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; + +class BulkMoveApplicationListEntriesAuditTest { + + @Test + void extractAuditData_includesSourceTargetAndMovedEntries() { + val sourceListId = 10L; + val targetListId = 20L; + val sourceListUuid = UUID.randomUUID(); + val targetListUuid = UUID.randomUUID(); + val firstEntry = entry(101L, UUID.randomUUID(), (short) 2, 7L); + val secondEntry = entry(102L, UUID.randomUUID(), (short) 4, 8L); + + val audit = + BulkMoveApplicationListEntriesAudit.forState( + sourceListId, + sourceListUuid, + targetListId, + targetListUuid, + List.of(firstEntry, secondEntry)); + + val auditData = audit.extractAuditData(CrudEnum.UPDATE); + + Assertions.assertEquals(sourceListId, audit.getId()); + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_move_source_list_uuid", + sourceListUuid.toString())); + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_move_target_list_uuid", + targetListUuid.toString())); + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_move_entry_count", + "2")); + val movedEntriesValue = fieldValue(auditData, "bulk_move_entries"); + Assertions.assertNotNull(movedEntriesValue); + Assertions.assertTrue(movedEntriesValue.contains(firstEntry.getUuid().toString())); + Assertions.assertTrue(movedEntriesValue.contains(secondEntry.getUuid().toString())); + Assertions.assertTrue(movedEntriesValue.contains("\"sequenceNumber\":2")); + Assertions.assertTrue(movedEntriesValue.contains("\"sequenceNumber\":4")); + Assertions.assertTrue(movedEntriesValue.contains("\"version\":7")); + Assertions.assertTrue(movedEntriesValue.contains("\"version\":8")); + } + + private ApplicationListEntry entry(Long id, UUID uuid, short sequenceNumber, Long version) { + val entry = new ApplicationListEntry(); + entry.setId(id); + entry.setUuid(uuid); + entry.setSequenceNumber(sequenceNumber); + entry.setVersion(version); + return entry; + } + + private boolean containsAuditRow( + List auditData, String tableName, String fieldName, String value) { + return auditData.stream() + .anyMatch( + row -> + tableName.equals(row.getTableName()) + && fieldName.equals(row.getFieldName()) + && value.equals(row.getValue())); + } + + private String fieldValue(List auditData, String fieldName) { + return auditData.stream() + .filter( + row -> + TableNames.APPLICATION_LISTS_ENTRY.equals(row.getTableName()) + && fieldName.equals(row.getFieldName())) + .map(AuditableData::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java index 2a9f6df57..fb60d2f63 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.notNull; @@ -35,6 +36,7 @@ import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.assertj.core.util.Lists; import org.instancio.Instancio; import org.instancio.settings.Keys; import org.instancio.settings.Settings; @@ -1625,25 +1627,32 @@ void move_resequencesEntries_whenValidRequest() { when(appListEntrySequenceMappingRepository.findByAlIdForUpdate(targetList.getId())) .thenReturn(Optional.of(mapping)); - when(applicationListEntryRepository.save(any(ApplicationListEntry.class))) - .thenAnswer(invocation -> invocation.getArgument(0, ApplicationListEntry.class)); - - ArgumentCaptor savedEntryCaptor = - ArgumentCaptor.forClass(ApplicationListEntry.class); + when(applicationListEntryRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0, List.class)); service.move(sourceListId, dto); verify(applicationListEntryRepository).findByUuidsInSourceList(eq(sourceListId), anySet()); - verify(applicationListEntryRepository, times(2)).save(savedEntryCaptor.capture()); - - List savedEntries = savedEntryCaptor.getAllValues(); - Assertions.assertEquals(2, savedEntries.size()); - Assertions.assertSame(entry2, savedEntries.get(0)); - Assertions.assertSame(entry1, savedEntries.get(1)); - Assertions.assertSame(targetList, savedEntries.get(0).getApplicationList()); - Assertions.assertSame(targetList, savedEntries.get(1).getApplicationList()); - Assertions.assertEquals((short) 3, savedEntries.get(0).getSequenceNumber()); - Assertions.assertEquals((short) 4, savedEntries.get(1).getSequenceNumber()); + verify(applicationListEntryRepository) + .saveAll( + argThat( + savedEntries -> { + var savedEntriesList = Lists.newArrayList(savedEntries); + Assertions.assertEquals(2, savedEntriesList.size()); + Assertions.assertSame(entry2, savedEntriesList.get(0)); + Assertions.assertSame(entry1, savedEntriesList.get(1)); + Assertions.assertSame( + targetList, + savedEntriesList.get(0).getApplicationList()); + Assertions.assertSame( + targetList, + savedEntriesList.get(1).getApplicationList()); + Assertions.assertEquals( + (short) 3, savedEntriesList.get(0).getSequenceNumber()); + Assertions.assertEquals( + (short) 4, savedEntriesList.get(1).getSequenceNumber()); + return true; + })); Assertions.assertEquals(4, mapping.getAleLastSequence()); } @@ -1688,7 +1697,7 @@ void move_throws_whenSomeRequestedEntriesAreMissingFromSourceList() { }); verify(applicationListEntryRepository).findByUuidsInSourceList(eq(sourceListId), anySet()); - verify(applicationListEntryRepository, never()).save(any(ApplicationListEntry.class)); + verify(applicationListEntryRepository, never()).saveAll(anyList()); } @Test From 98eb343099bdd026c7f1e99990e725824889f662 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 16:35:10 +0100 Subject: [PATCH 4/8] ARCPOC-1559 - Bulk audit fee updates. - Add a dedicated bulk fees audit payload and operation. - Batch fee status and offsite fee persistence under one audit operation. - Update unit and integration tests for single-record bulk fee auditing. --- ...pplicationEntryControllerBulkFeesTest.java | 22 ++-- .../audit/AppListEntryAuditOperation.java | 1 + .../audit/BulkUpdateFeesAudit.java | 105 +++++++++++++++ .../service/ApplicationEntryServiceImpl.java | 123 ++++++++++++++---- .../audit/BulkUpdateFeesAuditTest.java | 108 +++++++++++++++ .../ApplicationEntryServiceImplTest.java | 83 ++++++------ 6 files changed, 367 insertions(+), 75 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAuditTest.java diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkFeesTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkFeesTest.java index 2a2632e6e..d1f4a7350 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkFeesTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentry/ApplicationEntryControllerBulkFeesTest.java @@ -411,20 +411,20 @@ private void assertSuccessfulBulkUpdateAudited() { differenceLogAsserter.assertNoErrors(); differenceLogAsserter.assertDataAuditChange( DataAuditLogAsserter.getDataAuditAssertion( - TableNames.APPLICATION_LISTS_FEE_STATUS, - "alefs_fee_status", - null, - "REMITTED", - AppListEntryAuditOperation.CREATE_FEE_STATUS_ENTRY.getType().name(), - AppListEntryAuditOperation.CREATE_FEE_STATUS_ENTRY.getEventName())); + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_fees_fee_details", + "", + "", + AppListEntryAuditOperation.BULK_UPDATE_FEES.getType().name(), + AppListEntryAuditOperation.BULK_UPDATE_FEES.getEventName())); differenceLogAsserter.assertDataAuditChange( DataAuditLogAsserter.getDataAuditAssertion( - TableNames.APPLCATION_LISTS_ENTRY_FEE_ID, - "fee_fee_id", - null, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_fees_offsite_entry_ids", + "", "", - AppListEntryAuditOperation.CREATE_FEE_ENTRY.getType().name(), - AppListEntryAuditOperation.CREATE_FEE_ENTRY.getEventName())); + AppListEntryAuditOperation.BULK_UPDATE_FEES.getType().name(), + AppListEntryAuditOperation.BULK_UPDATE_FEES.getEventName())); } private void assertNoBulkFeeAuditWritten() { diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java index 67e34589a..f1f2d3a0a 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/AppListEntryAuditOperation.java @@ -12,6 +12,7 @@ public enum AppListEntryAuditOperation implements AuditOperation { BULK_IMPORT_APP_ENTRIES("Bulk Import Application List Entries", CrudEnum.CREATE), BULK_MOVE_APP_ENTRIES("Bulk Move Application List Entries", CrudEnum.UPDATE), BULK_UPDATE_OFFICIALS("Bulk Update Officials", CrudEnum.UPDATE), + BULK_UPDATE_FEES("Bulk Update Fees", CrudEnum.UPDATE), UPDATE_APP_ENTRY_LIST("Update Entry Application List", CrudEnum.UPDATE), UPDATE_CLOSED_APP_ENTRY_LIST("Update Closed Entry Application List", CrudEnum.UPDATE), CREATE_OFFICIAL_ENTRY("Create Official", CrudEnum.CREATE), diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java new file mode 100644 index 000000000..ac6f88398 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java @@ -0,0 +1,105 @@ +package uk.gov.hmcts.appregister.applicationentry.audit; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.AppListEntryFeeStatus; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.generated.model.BulkFeeDetailsDto; + +public record BulkUpdateFeesAudit( + Long listId, + UUID listUuid, + List entryIds, + int entryCount, + String feeDetails, + String offsiteEntryIds) + implements Auditable { + + private static final String TABLE_NAME = TableNames.APPLICATION_LISTS_ENTRY; + private static final String LIST_UUID_FIELD = "bulk_fees_list_uuid"; + private static final String ENTRY_IDS_FIELD = "bulk_fees_entry_ids"; + private static final String ENTRY_COUNT_FIELD = "bulk_fees_entry_count"; + private static final String FEE_DETAILS_FIELD = "bulk_fees_fee_details"; + private static final String OFFSITE_ENTRY_IDS_FIELD = "bulk_fees_offsite_entry_ids"; + + @Override + public Long getId() { + return listId; + } + + @Override + public List extractAuditData(CrudEnum crudEnum) { + return List.of( + new AuditableData(TABLE_NAME, LIST_UUID_FIELD, listUuid.toString()), + new AuditableData(TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + new AuditableData(TABLE_NAME, ENTRY_COUNT_FIELD, Integer.toString(entryCount)), + new AuditableData(TABLE_NAME, FEE_DETAILS_FIELD, feeDetails), + new AuditableData(TABLE_NAME, OFFSITE_ENTRY_IDS_FIELD, offsiteEntryIds)); + } + + public static String formatExistingFeeStatuses(List feeStatuses) { + return feeStatuses.stream() + .sorted( + Comparator.comparing( + (AppListEntryFeeStatus status) -> + status.getAppListEntry().getSequenceNumber()) + .thenComparing( + status -> status.getId(), + Comparator.nullsLast(Long::compareTo))) + .map( + status -> + ("{\"entryId\":\"%s\",\"status\":\"%s\",\"statusDate\":\"%s\"," + + "\"paymentReference\":\"%s\"}") + .formatted( + status.getAppListEntry().getUuid(), + status.getAlefsFeeStatus(), + status.getAlefsFeeStatusDate(), + escape(status.getAlefsPaymentReference()))) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String formatRequestedFeeDetails(List feeDetails) { + return feeDetails.stream() + .map( + feeDetail -> + ("{\"paymentStatus\":\"%s\",\"statusDate\":\"%s\"," + + "\"paymentReference\":\"%s\",\"hasOffsiteFee\":%s}") + .formatted( + feeDetail.getPaymentStatus(), + feeDetail.getStatusDate(), + escape(feeDetail.getPaymentReference()), + Boolean.TRUE.equals(feeDetail.getHasOffsiteFee()))) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String formatOffsiteEntryIds( + Collection entryIds, Map entryUuidsById) { + return entryIds.stream() + .map(entryUuidsById::get) + .sorted() + .map("\"%s\""::formatted) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String formatEntryIds(List entryIds) { + return entryIds.stream() + .sorted() + .map("\"%s\""::formatted) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java index 63d8c7b89..82d6429a3 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImpl.java @@ -30,6 +30,7 @@ import uk.gov.hmcts.appregister.applicationentry.audit.ApplicationListEntryReadAudit; import uk.gov.hmcts.appregister.applicationentry.audit.BulkApplicationListEntriesReadAudit; import uk.gov.hmcts.appregister.applicationentry.audit.BulkMoveApplicationListEntriesAudit; +import uk.gov.hmcts.appregister.applicationentry.audit.BulkUpdateFeesAudit; import uk.gov.hmcts.appregister.applicationentry.audit.BulkUpdateOfficialsAudit; import uk.gov.hmcts.appregister.applicationentry.audit.model.DeleteAuditable; import uk.gov.hmcts.appregister.applicationentry.exception.AppListEntryError; @@ -1029,10 +1030,27 @@ public BulkUpdateResponseDto bulkUpdateFees(UUID listId, BulkFeesUpdateDto bulkF boolean hasOffsiteFee = hasOffsiteFee(feeDetails); Supplier offsiteFeeSupplier = offsiteFeeSupplier(hasOffsiteFee); + List entryUuids = + entries.stream() + .map(ApplicationListEntry::getUuid) + .toList(); + Map entryUuidsById = + entries.stream() + .collect( + Collectors.toMap( + ApplicationListEntry::getId, + ApplicationListEntry::getUuid)); + List existingFeeStatuses = + appListEntryFeeStatusRepository.findByAppListEntry_UuidIn( + entryUuids); Set entryIdsWithOffsiteMapping = hasOffsiteFee ? getEntryIdsWithOffsiteMapping(entries) : Set.of(); + List feeStatusesToCreate = + new ArrayList<>(entries.size() * feeDetails.size()); + List offsiteFeeMappingsToCreate = + new ArrayList<>(); for (ApplicationListEntry entry : entries) { appendFeeDetailsForEntry( @@ -1040,15 +1058,67 @@ public BulkUpdateResponseDto bulkUpdateFees(UUID listId, BulkFeesUpdateDto bulkF feeDetails, hasOffsiteFee, offsiteFeeSupplier, - entryIdsWithOffsiteMapping); + entryIdsWithOffsiteMapping, + feeStatusesToCreate, + offsiteFeeMappingsToCreate); } + var oldAudit = + new BulkUpdateFeesAudit( + applicationListId(entries), + listId, + entryUuids, + entries.size(), + BulkUpdateFeesAudit.formatExistingFeeStatuses( + existingFeeStatuses), + BulkUpdateFeesAudit.formatOffsiteEntryIds( + entryIdsWithOffsiteMapping, + entryUuidsById)); + int updatedCount = entries.size(); + Set updatedOffsiteEntryIds = + new HashSet<>(entryIdsWithOffsiteMapping); + offsiteFeeMappingsToCreate.stream() + .map(AppListEntryFeeId::getAppListEntryId) + .forEach(updatedOffsiteEntryIds::add); + var newAudit = + new BulkUpdateFeesAudit( + applicationListId(entries), + listId, + entryUuids, + entries.size(), + BulkUpdateFeesAudit.formatRequestedFeeDetails( + feeDetails), + BulkUpdateFeesAudit.formatOffsiteEntryIds( + updatedOffsiteEntryIds, entryUuidsById)); + + return auditService.processAudit( + oldAudit, + AppListEntryAuditOperation.BULK_UPDATE_FEES, + ignored -> { + if (!feeStatusesToCreate.isEmpty()) { + appListEntryFeeStatusRepository.saveAll( + feeStatusesToCreate); + } + + if (!offsiteFeeMappingsToCreate.isEmpty()) { + appListEntryFeeRepository.saveAll( + offsiteFeeMappingsToCreate); + } + + var bulkUpdateResponse = + new BulkUpdateResponseDto() + .totalCount( + req.data().getEntryIds().size()) + .updatedCount(updatedCount) + .status( + BulkUpdateResponseDto.StatusEnum + .SUCCEEDED); - return new BulkUpdateResponseDto() - .totalCount(req.data().getEntryIds().size()) - .updatedCount(updatedCount) - .status(BulkUpdateResponseDto.StatusEnum.SUCCEEDED); + return Optional.of( + new AuditableResult<>( + bulkUpdateResponse, newAudit)); + }); }); long durationNanos = System.nanoTime() - startNanos; @@ -1123,13 +1193,19 @@ private void appendFeeDetailsForEntry( List feeDetails, boolean hasOffsiteFee, Supplier offsiteFeeSupplier, - Set entryIdsWithOffsiteMapping) { + Set entryIdsWithOffsiteMapping, + List feeStatusesToCreate, + List offsiteFeeMappingsToCreate) { for (BulkFeeDetailsDto feeDetail : feeDetails) { - saveFeeStatus(createBulkFeeStatus(entry, feeDetail), new ArrayList<>()); + feeStatusesToCreate.add(createBulkFeeStatus(entry, feeDetail)); } if (hasOffsiteFee) { - ensureOffsiteFeeMapping(entry, offsiteFeeSupplier, entryIdsWithOffsiteMapping); + ensureOffsiteFeeMapping( + entry, + offsiteFeeSupplier, + entryIdsWithOffsiteMapping, + offsiteFeeMappingsToCreate); } } @@ -1341,30 +1417,25 @@ private Set getEntryIdsWithOffsiteMapping(List entri private void ensureOffsiteFeeMapping( ApplicationListEntry entry, Supplier offsiteFeeSupplier, - Set entryIdsWithOffsiteMapping) { + Set entryIdsWithOffsiteMapping, + List offsiteFeeMappingsToCreate) { if (entryIdsWithOffsiteMapping.contains(entry.getId())) { return; } Fee offsiteFee = offsiteFeeSupplier.get(); + AppListEntryFeeId offsiteEntryFee = new AppListEntryFeeId(); + offsiteEntryFee.setAppListEntryId(entry.getId()); + offsiteEntryFee.setFeeId(offsiteFee.getId()); + offsiteFeeMappingsToCreate.add(offsiteEntryFee); + log.debug( + CREATED_OFFSITE_FEE_LOG, + offsiteEntryFee.getFeeId(), + offsiteEntryFee.getAppListEntryId()); + } - auditService.processAudit( - AppListEntryAuditOperation.CREATE_FEE_ENTRY, - req -> { - AppListEntryFeeId offsiteEntryFee = new AppListEntryFeeId(); - offsiteEntryFee.setAppListEntryId(entry.getId()); - offsiteEntryFee.setFeeId(offsiteFee.getId()); - - AppListEntryFeeId savedOffsiteEntryFee = - appListEntryFeeRepository.save(offsiteEntryFee); - - log.debug( - CREATED_OFFSITE_FEE_LOG, - savedOffsiteEntryFee.getFeeId(), - savedOffsiteEntryFee.getAppListEntryId()); - - return Optional.of(new AuditableResult<>(null, savedOffsiteEntryFee)); - }); + private static Long applicationListId(List entries) { + return entries.isEmpty() ? null : entries.getFirst().getApplicationList().getId(); } /** diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAuditTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAuditTest.java new file mode 100644 index 000000000..a7c0f7bc8 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAuditTest.java @@ -0,0 +1,108 @@ +package uk.gov.hmcts.appregister.applicationentry.audit; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import lombok.val; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.common.entity.AppListEntryFeeStatus; +import uk.gov.hmcts.appregister.common.entity.ApplicationList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.common.enumeration.FeeStatusType; +import uk.gov.hmcts.appregister.generated.model.BulkFeeDetailsDto; +import uk.gov.hmcts.appregister.generated.model.PaymentStatus; + +class BulkUpdateFeesAuditTest { + + @Test + void extractAuditData_includesEntryMetadataFeeDetailsAndOffsiteEntries() { + val listUuid = UUID.randomUUID(); + val entryId = UUID.randomUUID(); + val audit = + new BulkUpdateFeesAudit( + 10L, + listUuid, + List.of(entryId), + 1, + "[{\"paymentStatus\":\"PAID\"}]", + "[\"" + entryId + "\"]"); + + val auditData = audit.extractAuditData(CrudEnum.UPDATE); + + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_fees_list_uuid", + listUuid.toString())); + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_fees_fee_details", + "[{\"paymentStatus\":\"PAID\"}]")); + Assertions.assertTrue( + containsAuditRow( + auditData, + TableNames.APPLICATION_LISTS_ENTRY, + "bulk_fees_offsite_entry_ids", + "[\"" + entryId + "\"]")); + } + + @Test + void formatHelpers_renderExistingStatusesRequestedFeeDetailsAndOffsiteEntries() { + val applicationList = new ApplicationList(); + applicationList.setId(10L); + applicationList.setUuid(UUID.randomUUID()); + + val entry = new ApplicationListEntry(); + entry.setId(100L); + entry.setUuid(UUID.randomUUID()); + entry.setSequenceNumber((short) 2); + entry.setApplicationList(applicationList); + + val existingStatus = new AppListEntryFeeStatus(); + existingStatus.setId(200L); + existingStatus.setAppListEntry(entry); + existingStatus.setAlefsFeeStatus(FeeStatusType.REMITTED); + existingStatus.setAlefsFeeStatusDate(LocalDate.of(2026, 7, 20)); + existingStatus.setAlefsPaymentReference("PAY-001"); + + val requestedFeeDetails = + new BulkFeeDetailsDto() + .paymentStatus(PaymentStatus.PAID) + .statusDate(LocalDate.of(2026, 7, 20)) + .paymentReference("PAY-002") + .hasOffsiteFee(true); + + Assertions.assertEquals( + ("[{\"entryId\":\"%s\",\"status\":\"REMITTED\",\"statusDate\":\"2026-07-20\"," + + "\"paymentReference\":\"PAY-001\"}]") + .formatted(entry.getUuid()), + BulkUpdateFeesAudit.formatExistingFeeStatuses(List.of(existingStatus))); + Assertions.assertEquals( + "[{\"paymentStatus\":\"PAID\",\"statusDate\":\"2026-07-20\"," + + "\"paymentReference\":\"PAY-002\",\"hasOffsiteFee\":true}]", + BulkUpdateFeesAudit.formatRequestedFeeDetails(List.of(requestedFeeDetails))); + Assertions.assertEquals( + "[\"" + entry.getUuid() + "\"]", + BulkUpdateFeesAudit.formatOffsiteEntryIds( + Set.of(entry.getId()), Map.of(entry.getId(), entry.getUuid()))); + } + + private boolean containsAuditRow( + List auditData, String tableName, String fieldName, String value) { + return auditData.stream() + .anyMatch( + row -> + tableName.equals(row.getTableName()) + && fieldName.equals(row.getFieldName()) + && value.equals(row.getValue())); + } +} diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java index fb60d2f63..25792f424 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentry/service/ApplicationEntryServiceImplTest.java @@ -1709,11 +1709,13 @@ void bulkUpdateFees_appendsStatusesForValidatedEntries() { final var entry1 = applicationListEntry(applicationList, entryId1, 101L, (short) 2); val existingStatus1 = new AppListEntryFeeStatus(); existingStatus1.setId(201L); + existingStatus1.setAppListEntry(entry1); val entryId2 = UUID.randomUUID(); val entry2 = applicationListEntry(applicationList, entryId2, 102L, (short) 1); val existingStatus2 = new AppListEntryFeeStatus(); existingStatus2.setId(202L); + existingStatus2.setAppListEntry(entry2); final var dto = bulkFeesUpdateDto(Set.of(entryId1, entryId2), PaymentStatus.PAID, false); @@ -1721,21 +1723,25 @@ void bulkUpdateFees_appendsStatusesForValidatedEntries() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry1, entry2)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(entryId1)) - .thenReturn(List.of(existingStatus1)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(entryId2)) - .thenReturn(List.of(existingStatus2)); - stubFeeStatusSave(); + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(List.of(entryId2, entryId1))) + .thenReturn(List.of(existingStatus1, existingStatus2)); final BulkUpdateResponseDto response = service.bulkUpdateFees(listId, dto); - ArgumentCaptor statusCaptor = - ArgumentCaptor.forClass(AppListEntryFeeStatus.class); verify(appListEntryFeeStatusRepository, never()).delete(existingStatus1); verify(appListEntryFeeStatusRepository, never()).delete(existingStatus2); - verify(appListEntryFeeStatusRepository, times(2)).save(statusCaptor.capture()); + @SuppressWarnings("unchecked") + ArgumentCaptor> statusCaptor = + ArgumentCaptor.forClass(List.class); + verify(auditOperationService) + .processAudit(any(), eq(AppListEntryAuditOperation.BULK_UPDATE_FEES), any()); + verify(auditOperationService, never()) + .processAudit(eq(AppListEntryAuditOperation.CREATE_FEE_STATUS_ENTRY), any()); + verify(auditOperationService, never()) + .processAudit(eq(AppListEntryAuditOperation.CREATE_FEE_ENTRY), any()); + verify(appListEntryFeeStatusRepository).saveAll(statusCaptor.capture()); - List savedStatuses = statusCaptor.getAllValues(); + List savedStatuses = statusCaptor.getValue(); Assertions.assertEquals( List.of(102L, 101L), savedStatuses.stream().map(status -> status.getAppListEntry().getId()).toList()); @@ -1804,20 +1810,21 @@ void bulkUpdateFees_appendsAllProvidedFeeDetails() { when(applicationListRepository.findByUuidIncludingDelete(listId)) .thenReturn(Optional.of(applicationList)); val entry = applicationListEntry(applicationList, entryId, 101L, (short) 1); + existingStatus.setAppListEntry(entry); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(entryId)) + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(List.of(entryId))) .thenReturn(List.of(existingStatus)); - stubFeeStatusSave(); service.bulkUpdateFees(listId, dto); - ArgumentCaptor statusCaptor = - ArgumentCaptor.forClass(AppListEntryFeeStatus.class); verify(appListEntryFeeStatusRepository, never()).delete(existingStatus); - verify(appListEntryFeeStatusRepository, times(2)).save(statusCaptor.capture()); + @SuppressWarnings("unchecked") + ArgumentCaptor> statusCaptor = + ArgumentCaptor.forClass(List.class); + verify(appListEntryFeeStatusRepository).saveAll(statusCaptor.capture()); - List savedStatuses = statusCaptor.getAllValues(); + List savedStatuses = statusCaptor.getValue(); Assertions.assertEquals( List.of(FeeStatusType.PAID, FeeStatusType.REMITTED), savedStatuses.stream().map(AppListEntryFeeStatus::getAlefsFeeStatus).toList()); @@ -1860,7 +1867,7 @@ void bulkUpdateFees_recordsFailedMetricWhenValidationFails() { .find(BULK_FEE_UPDATE_ENTRIES_METRIC) .tag(METRIC_STATUS_TAG, "failed") .summary()); - verify(appListEntryFeeStatusRepository, never()).save(any(AppListEntryFeeStatus.class)); + verify(appListEntryFeeStatusRepository, never()).saveAll(anyList()); } @Test @@ -1885,9 +1892,8 @@ void bulkUpdateFees_updatesEntriesAtOperationalLimit() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(entries); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(any(UUID.class))) + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(anyList())) .thenReturn(List.of()); - stubFeeStatusSave(); BulkUpdateResponseDto response = service.bulkUpdateFees(listId, dto); @@ -1895,7 +1901,7 @@ void bulkUpdateFees_updatesEntriesAtOperationalLimit() { Assertions.assertEquals(1050, response.getUpdatedCount()); Assertions.assertEquals(BulkUpdateResponseDto.StatusEnum.SUCCEEDED, response.getStatus()); verify(applicationListEntryRepository).findByUuidsInSourceList(eq(listId), anySet()); - verify(appListEntryFeeStatusRepository, times(1050)).save(any(AppListEntryFeeStatus.class)); + verify(appListEntryFeeStatusRepository).saveAll(anyList()); verify(appListEntryFeeRepository, never()).getOffsiteEntryFeesForEntries(anyList()); verify(feeRepository, never()).findOffsite(any(LocalDate.class)); } @@ -1915,23 +1921,25 @@ void bulkUpdateFees_createsOffsiteFeeMappingWhenRequestedAndMissing() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(entryId)) + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(List.of(entryId))) .thenReturn(List.of()); when(appListEntryFeeRepository.getOffsiteEntryFeesForEntries(anyList())) .thenReturn(List.of()); when(feeRepository.findOffsite(LocalDate.of(2025, Month.OCTOBER, 7))) .thenReturn(List.of(offsiteFee)); - when(appListEntryFeeRepository.save(any(AppListEntryFeeId.class))) + when(appListEntryFeeRepository.saveAll(anyList())) .thenAnswer(invocation -> invocation.getArgument(0)); - stubFeeStatusSave(); service.bulkUpdateFees(listId, dto); - ArgumentCaptor feeMappingCaptor = - ArgumentCaptor.forClass(AppListEntryFeeId.class); - verify(appListEntryFeeRepository).save(feeMappingCaptor.capture()); - Assertions.assertEquals(entry.getId(), feeMappingCaptor.getValue().getAppListEntryId()); - Assertions.assertEquals(offsiteFee.getId(), feeMappingCaptor.getValue().getFeeId()); + @SuppressWarnings("unchecked") + ArgumentCaptor> feeMappingCaptor = + ArgumentCaptor.forClass(List.class); + verify(appListEntryFeeRepository).saveAll(feeMappingCaptor.capture()); + Assertions.assertEquals( + entry.getId(), feeMappingCaptor.getValue().getFirst().getAppListEntryId()); + Assertions.assertEquals( + offsiteFee.getId(), feeMappingCaptor.getValue().getFirst().getFeeId()); } @Test @@ -1951,29 +1959,29 @@ void bulkUpdateFees_reusesActiveOffsiteFeeWhenMultipleMappingsAreMissing() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry1, entry2)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(any(UUID.class))) + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(anyList())) .thenReturn(List.of()); when(appListEntryFeeRepository.getOffsiteEntryFeesForEntries(anyList())) .thenReturn(List.of()); when(feeRepository.findOffsite(LocalDate.of(2025, Month.OCTOBER, 7))) .thenReturn(List.of(offsiteFee)); - when(appListEntryFeeRepository.save(any(AppListEntryFeeId.class))) + when(appListEntryFeeRepository.saveAll(anyList())) .thenAnswer(invocation -> invocation.getArgument(0)); - stubFeeStatusSave(); service.bulkUpdateFees(listId, dto); - ArgumentCaptor feeMappingCaptor = - ArgumentCaptor.forClass(AppListEntryFeeId.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> feeMappingCaptor = + ArgumentCaptor.forClass(List.class); verify(feeRepository).findOffsite(LocalDate.of(2025, Month.OCTOBER, 7)); - verify(appListEntryFeeRepository, times(2)).save(feeMappingCaptor.capture()); + verify(appListEntryFeeRepository).saveAll(feeMappingCaptor.capture()); Assertions.assertEquals( Set.of(entry1.getId(), entry2.getId()), - feeMappingCaptor.getAllValues().stream() + feeMappingCaptor.getValue().stream() .map(AppListEntryFeeId::getAppListEntryId) .collect(Collectors.toSet())); Assertions.assertTrue( - feeMappingCaptor.getAllValues().stream() + feeMappingCaptor.getValue().stream() .allMatch(mapping -> offsiteFee.getId().equals(mapping.getFeeId()))); } @@ -1992,16 +2000,15 @@ void bulkUpdateFees_preservesExistingOffsiteFeeMappingWhenNotRequested() { .thenReturn(Optional.of(applicationList)); when(applicationListEntryRepository.findByUuidsInSourceList(eq(listId), anySet())) .thenReturn(List.of(entry)); - when(appListEntryFeeStatusRepository.getFeeStatusByEntryUuid(entryId)) + when(appListEntryFeeStatusRepository.findByAppListEntry_UuidIn(List.of(entryId))) .thenReturn(List.of()); - stubFeeStatusSave(); service.bulkUpdateFees(listId, dto); verify(appListEntryFeeRepository, never()).delete(existingOffsiteMapping); verify(appListEntryFeeRepository, never()).flush(); verify(appListEntryFeeRepository, never()).getOffsiteEntryFeesForEntries(anyList()); - verify(appListEntryFeeRepository, never()).save(any(AppListEntryFeeId.class)); + verify(appListEntryFeeRepository, never()).saveAll(anyList()); } private ApplicationList openApplicationList(UUID listId) { From cace7fb3b303c8b6468cecf1dd1c2c37891457f5 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 16:50:17 +0100 Subject: [PATCH 5/8] ARCPOC-1559 - Optimise bulk result audit shaping. - Reuse persisted bulk result entities after flush instead of reloading them. - Remove the redundant bulk result repository lookup from the service test. - Extract shared bulk audit formatting helpers and add focused tests. --- .../audit/BulkUpdateFeesAudit.java | 33 +++++--------- .../audit/BulkUpdateOfficialsAudit.java | 33 +++++--------- ...BulkCreateApplicationEntryResultAudit.java | 33 +++++--------- .../ApplicationEntryResultServiceImpl.java | 25 ++++------- .../listener/diff/BulkAuditFormatting.java | 27 +++++++++++ ...CreateApplicationEntryResultAuditTest.java | 45 +++++++++++++++++++ ...ApplicationEntryResultServiceImplTest.java | 4 +- .../diff/BulkAuditFormattingTest.java | 25 +++++++++++ 8 files changed, 142 insertions(+), 83 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormatting.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormattingTest.java diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java index ac6f88398..fde03ba9a 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateFeesAudit.java @@ -8,6 +8,7 @@ import java.util.stream.Collectors; import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.audit.listener.diff.BulkAuditFormatting; import uk.gov.hmcts.appregister.common.entity.AppListEntryFeeStatus; import uk.gov.hmcts.appregister.common.entity.TableNames; import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; @@ -38,7 +39,10 @@ public Long getId() { public List extractAuditData(CrudEnum crudEnum) { return List.of( new AuditableData(TABLE_NAME, LIST_UUID_FIELD, listUuid.toString()), - new AuditableData(TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + new AuditableData( + TABLE_NAME, + ENTRY_IDS_FIELD, + BulkAuditFormatting.formatSortedUuidArray(entryIds)), new AuditableData(TABLE_NAME, ENTRY_COUNT_FIELD, Integer.toString(entryCount)), new AuditableData(TABLE_NAME, FEE_DETAILS_FIELD, feeDetails), new AuditableData(TABLE_NAME, OFFSITE_ENTRY_IDS_FIELD, offsiteEntryIds)); @@ -61,7 +65,8 @@ public static String formatExistingFeeStatuses(List feeSt status.getAppListEntry().getUuid(), status.getAlefsFeeStatus(), status.getAlefsFeeStatusDate(), - escape(status.getAlefsPaymentReference()))) + BulkAuditFormatting.escape( + status.getAlefsPaymentReference()))) .collect(Collectors.joining(",", "[", "]")); } @@ -74,7 +79,8 @@ public static String formatRequestedFeeDetails(List feeDetail .formatted( feeDetail.getPaymentStatus(), feeDetail.getStatusDate(), - escape(feeDetail.getPaymentReference()), + BulkAuditFormatting.escape( + feeDetail.getPaymentReference()), Boolean.TRUE.equals(feeDetail.getHasOffsiteFee()))) .collect(Collectors.joining(",", "[", "]")); } @@ -83,23 +89,8 @@ public static String formatOffsiteEntryIds( Collection entryIds, Map entryUuidsById) { return entryIds.stream() .map(entryUuidsById::get) - .sorted() - .map("\"%s\""::formatted) - .collect(Collectors.joining(",", "[", "]")); - } - - private static String formatEntryIds(List entryIds) { - return entryIds.stream() - .sorted() - .map("\"%s\""::formatted) - .collect(Collectors.joining(",", "[", "]")); - } - - private static String escape(String value) { - if (value == null) { - return ""; - } - - return value.replace("\\", "\\\\").replace("\"", "\\\""); + .collect( + Collectors.collectingAndThen( + Collectors.toList(), BulkAuditFormatting::formatSortedUuidArray)); } } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java index f10fcb23c..c7e2318a9 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/audit/BulkUpdateOfficialsAudit.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.audit.listener.diff.BulkAuditFormatting; import uk.gov.hmcts.appregister.common.entity.AppListEntryOfficial; import uk.gov.hmcts.appregister.common.entity.TableNames; import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; @@ -30,7 +31,10 @@ public Long getId() { public List extractAuditData(CrudEnum crudEnum) { return List.of( new AuditableData(TABLE_NAME, LIST_UUID_FIELD, listUuid.toString()), - new AuditableData(TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + new AuditableData( + TABLE_NAME, + ENTRY_IDS_FIELD, + BulkAuditFormatting.formatSortedUuidArray(entryIds)), new AuditableData(TABLE_NAME, ENTRY_COUNT_FIELD, Integer.toString(entryCount)), new AuditableData(TABLE_NAME, OFFICIALS_FIELD, officials)); } @@ -53,9 +57,9 @@ public static String formatDeletedOfficials(List deletedOf official.getAppListEntry().getUuid(), official.getAppListEntry().getSequenceNumber(), official.getOfficialType(), - escape(official.getTitle()), - escape(official.getForename()), - escape(official.getSurname()))) + BulkAuditFormatting.escape(official.getTitle()), + BulkAuditFormatting.escape(official.getForename()), + BulkAuditFormatting.escape(official.getSurname()))) .collect(Collectors.joining(",", "[", "]")); } @@ -66,24 +70,9 @@ public static String formatReplacementOfficials(List replacementOffici "{\"type\":\"%s\",\"title\":\"%s\",\"forename\":\"%s\",\"surname\":\"%s\"}" .formatted( official.getType(), - escape(official.getTitle()), - escape(official.getForename()), - escape(official.getSurname()))) + BulkAuditFormatting.escape(official.getTitle()), + BulkAuditFormatting.escape(official.getForename()), + BulkAuditFormatting.escape(official.getSurname()))) .collect(Collectors.joining(",", "[", "]")); } - - private static String formatEntryIds(List entryIds) { - return entryIds.stream() - .sorted() - .map("\"%s\""::formatted) - .collect(Collectors.joining(",", "[", "]")); - } - - private static String escape(String value) { - if (value == null) { - return ""; - } - - return value.replace("\\", "\\\\").replace("\"", "\\\""); - } } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java index d6ebcaa80..30ddbeb0f 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAudit.java @@ -7,6 +7,7 @@ import java.util.stream.Collectors; import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.audit.listener.diff.BulkAuditFormatting; import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; import uk.gov.hmcts.appregister.common.entity.TableNames; import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; @@ -41,7 +42,9 @@ public List extractAuditData(CrudEnum crudEnum) { new ArrayList<>( List.of( new AuditableData( - TABLE_NAME, ENTRY_IDS_FIELD, formatEntryIds(entryIds)), + TABLE_NAME, + ENTRY_IDS_FIELD, + BulkAuditFormatting.formatSortedUuidArray(entryIds)), new AuditableData( TABLE_NAME, ENTRY_COUNT_FIELD, @@ -68,8 +71,9 @@ public static String formatWordingFields(List wordingField substitution -> "{\"key\":\"%s\",\"value\":\"%s\"}" .formatted( - escape(substitution.getKey()), - escape(substitution.getValue()))) + BulkAuditFormatting.escape(substitution.getKey()), + BulkAuditFormatting.escape( + substitution.getValue()))) .collect(Collectors.joining(",", "[", "]")); } @@ -93,27 +97,14 @@ public static String formatCreatedResults(List createdRe resolution.getUuid(), resolution.getApplicationList().getUuid(), resolution.getApplicationList().getSequenceNumber(), - escape( + BulkAuditFormatting.escape( resolution .getResolutionCode() .getResultCode()), - escape(resolution.getResolutionWording()), - escape(resolution.getResolutionOfficer()))) - .collect(Collectors.joining(",", "[", "]")); - } - - private static String formatEntryIds(List entryIds) { - return entryIds.stream() - .sorted() - .map("\"%s\""::formatted) + BulkAuditFormatting.escape( + resolution.getResolutionWording()), + BulkAuditFormatting.escape( + resolution.getResolutionOfficer()))) .collect(Collectors.joining(",", "[", "]")); } - - private static String escape(String value) { - if (value == null) { - return ""; - } - - return value.replace("\\", "\\\\").replace("\"", "\\\""); - } } diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java index 35fa6260b..6f6acf8b2 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java @@ -348,26 +348,19 @@ public List bulkCreate(PayloadForCreateResults bulk AppListEntryResultAuditOperation .BULK_CREATE_APP_LIST_ENTRY_RESULT, ignored -> { - repository.saveAll(entitiesToCreate); - entityManager.flush(); - - var reloadedEntities = + var persistedEntities = new ArrayList<>( - repository.findAllById( - entitiesToCreate.stream() - .map( - AppListEntryResolution - ::getId) - .toList())); - reloadedEntities.sort( + repository.saveAll(entitiesToCreate)); + entityManager.flush(); + persistedEntities.sort( Comparator.comparing( AppListEntryResolution::getId)); var bulkAudit = new BulkCreateApplicationEntryResultAudit( - reloadedEntities.isEmpty() + persistedEntities.isEmpty() ? null - : reloadedEntities + : persistedEntities .getFirst() .getId(), validate.getListId(), @@ -377,7 +370,7 @@ public List bulkCreate(PayloadForCreateResults bulk item.payload() .getEntryId()) .toList(), - reloadedEntities.size(), + persistedEntities.size(), resultPayload.getResultCode(), BulkCreateApplicationEntryResultAudit .formatWordingFields( @@ -385,10 +378,10 @@ public List bulkCreate(PayloadForCreateResults bulk .getWordingFields()), BulkCreateApplicationEntryResultAudit .formatCreatedResults( - reloadedEntities)); + persistedEntities)); var resultDtos = - reloadedEntities.stream() + persistedEntities.stream() .map( applicationListEntryResultMapper ::toResultGetDto) diff --git a/src/main/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormatting.java b/src/main/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormatting.java new file mode 100644 index 000000000..f810967ef --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormatting.java @@ -0,0 +1,27 @@ +package uk.gov.hmcts.appregister.audit.listener.diff; + +import java.util.Collection; +import java.util.UUID; +import java.util.stream.Collectors; + +public final class BulkAuditFormatting { + + private BulkAuditFormatting() { + // Utility class. + } + + public static String formatSortedUuidArray(Collection entryIds) { + return entryIds.stream() + .sorted() + .map("\"%s\""::formatted) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String escape(String value) { + if (value == null) { + return ""; + } + + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java new file mode 100644 index 000000000..3bc61c328 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java @@ -0,0 +1,45 @@ +package uk.gov.hmcts.appregister.applicationentryresult.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.ResolutionCode; +import uk.gov.hmcts.appregister.generated.model.TemplateSubstitution; + +class BulkCreateApplicationEntryResultAuditTest { + + @Test + void formatHelpers_renderExpectedEscapedValues() { + var entry = new ApplicationListEntry(); + entry.setId(22L); + entry.setUuid(UUID.fromString("22222222-2222-2222-2222-222222222222")); + entry.setSequenceNumber((short) 7); + + var resolutionCode = new ResolutionCode(); + resolutionCode.setResultCode("CODE\\\"1"); + + var resolution = new AppListEntryResolution(); + resolution.setId(11L); + resolution.setUuid(UUID.fromString("11111111-1111-1111-1111-111111111111")); + resolution.setApplicationList(entry); + resolution.setResolutionCode(resolutionCode); + resolution.setResolutionWording("Wording\\\"1"); + resolution.setResolutionOfficer("Officer\\\"1"); + + assertThat( + BulkCreateApplicationEntryResultAudit.formatWordingFields( + List.of(new TemplateSubstitution().key("k\\\"1").value("v\\\"1")))) + .isEqualTo("[{\"key\":\"k\\\\\\\"1\",\"value\":\"v\\\\\\\"1\"}]"); + assertThat(BulkCreateApplicationEntryResultAudit.formatCreatedResults(List.of(resolution))) + .isEqualTo( + "[{\"resultId\":\"11111111-1111-1111-1111-111111111111\"," + + "\"entryId\":\"22222222-2222-2222-2222-222222222222\"," + + "\"sequenceNumber\":7,\"resultCode\":\"CODE\\\\\\\"1\"," + + "\"wording\":\"Wording\\\\\\\"1\"," + + "\"officer\":\"Officer\\\\\\\"1\"}]"); + } +} diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java index b671b2738..b5a99d38d 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java @@ -415,8 +415,6 @@ void bulkProcessEntries() { when(appListEntryResolutionRepository.saveAll(List.of(entryToSave))) .thenReturn(List.of(entryToSave)); - when(appListEntryResolutionRepository.findAllById(List.of(entryToSave.getId()))) - .thenReturn(List.of(entryToSave)); // setup the response of the call ResultGetDto resultGetDto = @@ -440,7 +438,7 @@ void bulkProcessEntries() { Assertions.assertEquals(List.of(resultGetDto), createdResults); verify(appListEntryResolutionRepository).saveAll(List.of(entryToSave)); - verify(appListEntryResolutionRepository).findAllById(List.of(entryToSave.getId())); + verify(appListEntryResolutionRepository, never()).findAllById(any()); verify(entityManager).flush(); verify(auditOperationService) .processAudit( diff --git a/src/test/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormattingTest.java b/src/test/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormattingTest.java new file mode 100644 index 000000000..d891537fe --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/audit/listener/diff/BulkAuditFormattingTest.java @@ -0,0 +1,25 @@ +package uk.gov.hmcts.appregister.audit.listener.diff; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class BulkAuditFormattingTest { + + @Test + void formatSortedUuidArray_sortsAndFormatsValues() { + var first = UUID.fromString("11111111-1111-1111-1111-111111111111"); + var second = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + assertThat(BulkAuditFormatting.formatSortedUuidArray(List.of(second, first))) + .isEqualTo("[\"%s\",\"%s\"]".formatted(first, second)); + } + + @Test + void escape_returnsEmptyStringForNullAndEscapesQuotesAndBackslashes() { + assertThat(BulkAuditFormatting.escape(null)).isEmpty(); + assertThat(BulkAuditFormatting.escape("a\\b\"c")).isEqualTo("a\\\\b\\\"c"); + } +} From 50b04e0f3c3b7d639c670498b277fb509a033339 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Mon, 20 Jul 2026 17:29:14 +0100 Subject: [PATCH 6/8] ARCPOC-1559 - Optimise bulk result audit shaping. - Fix test --- .../audit/BulkCreateApplicationEntryResultAuditTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java index 3bc61c328..7c2862eee 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkCreateApplicationEntryResultAuditTest.java @@ -38,7 +38,8 @@ void formatHelpers_renderExpectedEscapedValues() { .isEqualTo( "[{\"resultId\":\"11111111-1111-1111-1111-111111111111\"," + "\"entryId\":\"22222222-2222-2222-2222-222222222222\"," - + "\"sequenceNumber\":7,\"resultCode\":\"CODE\\\\\\\"1\"," + + "\"sequenceNumber\":7,\n" + + "\"resultCode\":\"CODE\\\\\\\"1\"," + "\"wording\":\"Wording\\\\\\\"1\"," + "\"officer\":\"Officer\\\\\\\"1\"}]"); } From 78bfcc645917e06a28aebf8b7d83f9a7c62bded1 Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Tue, 21 Jul 2026 12:49:25 +0100 Subject: [PATCH 7/8] ARCPOC-1559 - Add bulk application entry result deletion. - Add the bulk delete application entry results endpoint and request contract. - Implement atomic bulk validation and batched deletion for entry results. - Add a single bulk audit event capturing the requested and deleted result rows. - Add unit and integration coverage for successful bulk deletion and rollback on validation failure. - Update jackson 2.x to newer version. --- build.gradle | 6 +- config/owasp/suppressions.xml | 12 +- openapi.yaml | 137 +++++++++++++- ...bstractApplicationEntryResultCrudTest.java | 7 + ...onEntryResultControllerBulkDeleteTest.java | 161 ++++++++++++++++ .../testutils/client/RestAssuredClient.java | 9 + .../exception/AppListEntryError.java | 2 +- .../validator/BulkUpdateFeesValidator.java | 2 +- .../AppListEntryResultAuditOperation.java | 2 + ...BulkDeleteApplicationEntryResultAudit.java | 92 ++++++++++ .../ApplicationEntryResultController.java | 8 + .../ApplicationEntryResultService.java | 3 + .../ApplicationEntryResultServiceImpl.java | 66 +++++++ ...ApplicationEntryResultDeletionSuccess.java | 10 + ...ationEntryResultDeletionValidatedItem.java | 6 + ...plicationEntryResultDeletionValidator.java | 173 ++++++++++++++++++ .../common/entity/AppListEntryFeeStatus.java | 2 +- .../AppListEntryResolutionRepository.java | 9 + .../ApplicationListEntryRepository.java | 22 +++ .../repository/ApplicationListRepository.java | 8 + .../application-code-get-detail-dto.yaml | 2 + .../application-code-get-summary-dto.yaml | 2 + .../bulk-fee-details-dto.yaml | 2 +- .../bulk-delete-result-item-dto.yaml | 22 +++ .../bulk-delete-results-dto.yaml | 14 ++ .../entries-bulk-result.yaml | 30 +++ ...DeleteApplicationEntryResultAuditTest.java | 77 ++++++++ .../ApplicationEntryResultControllerTest.java | 12 ++ ...ApplicationEntryResultServiceImplTest.java | 127 +++++++++++++ ...ationEntryResultDeletionValidatorTest.java | 170 +++++++++++++++++ 30 files changed, 1176 insertions(+), 19 deletions(-) create mode 100644 src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerBulkDeleteTest.java create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAudit.java create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionSuccess.java create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatedItem.java create mode 100644 src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidator.java create mode 100644 src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-result-item-dto.yaml create mode 100644 src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-results-dto.yaml create mode 100644 src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAuditTest.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatorTest.java diff --git a/build.gradle b/build.gradle index bd95d2c56..c2fa01e26 100644 --- a/build.gradle +++ b/build.gradle @@ -454,9 +454,9 @@ dependencies { // Future Jackson 2 overrides for CVE-2026-54515. // Leave commented until the FasterXML 2.x fixes are actually published to Maven Central. - // implementation 'com.fasterxml.jackson.core:jackson-core:2.21.5' - // implementation 'com.fasterxml.jackson.core:jackson-databind:2.21.5' - // implementation 'com.fasterxml.jackson.core:jackson-annotations:2.21.5' + implementation 'com.fasterxml.jackson.core:jackson-core:2.21.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.21.5' + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.21.5' implementation 'org.flywaydb:flyway-database-postgresql:12.11.0' diff --git a/config/owasp/suppressions.xml b/config/owasp/suppressions.xml index 12ae69b55..0dc3d3597 100644 --- a/config/owasp/suppressions.xml +++ b/config/owasp/suppressions.xml @@ -8,23 +8,13 @@ ^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$ CVE-2023-35116 + We do not expose a SMTP interface CVE-2025-7962 - - - Temporary suppression: CVE-2026-54515 affects Jackson 2.21.4, but the advisory's fixed - FasterXML 2.x versions were not published to Maven Central when investigated on 2026-07-01. - Review when a published Jackson 2.x fix is available to replace this suppression with a real - upgrade. - - ^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@2\.21\.4$ - CVE-2026-54515 - - diff --git a/openapi.yaml b/openapi.yaml index 320860062..6d1d253ca 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3473,6 +3473,100 @@ paths: tags: - application-list-entry-results /application-lists/entries/results: + delete: + description: | + Atomically deletes the specified Application List Entry Results. Each item is validated using the same rules as the single delete endpoint. If any item cannot be deleted, no Results are removed. + operationId: bulkDeleteResultEntries + requestBody: + content: + application/vnd.hmcts.appreg.v1+json: + schema: + $ref: "#/components/schemas/bulk-delete-results-dto" + required: true + responses: + "204": + description: Results deleted for all supplied items. + headers: + Vary: + description: Response varies by Accept for media-type versioning. + schema: + example: Accept + type: string + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/problem" + description: Invalid request parameters. + "401": + content: + application/problem+json: + examples: + unauthenticated: + value: + type: https://errors.hmcts.net/common/unauthorized + title: Unauthorized + status: 401 + detail: Missing or invalid credentials + schema: + $ref: "#/components/schemas/problem" + description: Authentication required or token invalid. + "403": + content: + application/problem+json: + examples: + forbidden: + value: + type: https://errors.hmcts.net/common/forbidden + title: Forbidden + status: 403 + detail: You do not have permission to access this resource + schema: + $ref: "#/components/schemas/problem" + description: Authenticated but not permitted to perform this action. + "404": + content: + application/problem+json: + examples: + missing: + value: + type: https://errors.hmcts.net/appreg/not-found + title: Not Found + status: 404 + detail: Result code with id=123 was not found + schema: + $ref: "#/components/schemas/problem" + description: The requested resource was not found. + "409": + content: + application/problem+json: + examples: + generic: + value: + type: https://errors.hmcts.net/common/conflict + title: Conflict + status: 409 + detail: The Application List could not be modified due to a conflict + with its current state + schema: + $ref: "#/components/schemas/problem" + description: Conflict with the current state of the resource. + "500": + content: + application/problem+json: + examples: + generic: + value: + type: https://errors.hmcts.net/common/internal-error + title: Internal Server Error + status: 500 + detail: An unexpected error occurred + schema: + $ref: "#/components/schemas/problem" + description: Unexpected server error. + summary: Delete multiple Application List Entry Results. + tags: + - application-list-entry-results post: description: | Atomically creates a **separate Result** for each specified entry in the **source** list (path `listId`), using the same Result payload for all entries. If any entry cannot be resulted, **no Results are created**. @@ -6156,6 +6250,7 @@ components: feeReference: description: A short reference code that identifies a Fee. example: CO5.2 + maxLength: 12 nullable: true type: string feeAmount: @@ -6167,6 +6262,7 @@ components: offsiteFeeReference: description: A short reference code that identifies a offsite Fee. example: CO1.1 + maxLength: 12 nullable: true type: string offsiteFeeAmount: @@ -6305,6 +6401,7 @@ components: feeReference: description: A short reference code that identifies a Fee. example: CO5.2 + maxLength: 12 nullable: true type: string feeAmount: @@ -6317,6 +6414,7 @@ components: offsiteFeeReference: description: A short reference code that identifies a offsite Fee. example: CO5.2 + maxLength: 12 nullable: true type: string offsiteFeeAmount: @@ -7378,6 +7476,20 @@ components: - id - resultCode type: object + bulk-delete-results-dto: + additionalProperties: false + description: Delete multiple Application List Entry Results atomically. + properties: + results: + description: | + Result delete requests to execute. Each item is validated using the same rules as the single-result delete endpoint. If any item is invalid, no results are deleted. + items: + $ref: "#/components/schemas/bulk-delete-result-item-dto" + minItems: 1 + type: array + required: + - results + type: object result-page: allOf: - $ref: "#/components/schemas/page" @@ -8228,7 +8340,7 @@ components: paymentReference: description: Optional reference used to identify the payment. example: PAY-001 - maxLength: 100 + maxLength: 15 minLength: 1 type: string hasOffsiteFee: @@ -8264,6 +8376,29 @@ components: - COMPLETED example: RECEIVED type: string + bulk-delete-result-item-dto: + additionalProperties: false + properties: + listId: + description: Public identifier of the Application List. + example: 123e4567-e89b-12d3-a456-426655440000 + format: uuid + type: string + entryId: + description: Public identifier of the Application List Entry. + example: 123e4567-e89b-12d3-a456-426655440100 + format: uuid + type: string + resultId: + description: Public identifier of the Application List Entry Result. + example: 123e4567-e89b-12d3-a456-426655440200 + format: uuid + type: string + required: + - entryId + - listId + - resultId + type: object court-location-get-summary-dto: description: "Lightweight DTO for Court Locations, used in list/search views." properties: diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/AbstractApplicationEntryResultCrudTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/AbstractApplicationEntryResultCrudTest.java index 91c5631ae..9bf7b68b8 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/AbstractApplicationEntryResultCrudTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/AbstractApplicationEntryResultCrudTest.java @@ -35,6 +35,7 @@ import uk.gov.hmcts.appregister.data.ResolutionCodeTestData; import uk.gov.hmcts.appregister.generated.model.ApplicationListCreateDto; import uk.gov.hmcts.appregister.generated.model.ApplicationListStatus; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.EntryCreateDto; import uk.gov.hmcts.appregister.generated.model.EntryGetDetailDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; @@ -130,6 +131,12 @@ protected Response createBulkResult(TokenAndJwksKey token, Object body) getLocalUrl(WEB_CONTEXT + "/entries/results"), token, body); } + protected Response deleteBulkResult(TokenAndJwksKey token, BulkDeleteResultsDto body) + throws MalformedURLException { + return restAssuredClient.executeDeleteRequest( + getLocalUrl(WEB_CONTEXT + "/entries/results"), token, body); + } + protected Response updateResult( UUID listId, UUID entryId, diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerBulkDeleteTest.java b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerBulkDeleteTest.java new file mode 100644 index 000000000..0edda55b1 --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/controller/applicationentryresult/ApplicationEntryResultControllerBulkDeleteTest.java @@ -0,0 +1,161 @@ +package uk.gov.hmcts.appregister.controller.applicationentryresult; + +import static org.assertj.core.api.Assertions.assertThat; +import static uk.gov.hmcts.appregister.common.enumeration.Status.OPEN; +import static uk.gov.hmcts.appregister.testutils.util.ProblemAssertUtil.assertEquals; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import uk.gov.hmcts.appregister.applicationentryresult.audit.AppListEntryResultAuditOperation; +import uk.gov.hmcts.appregister.applicationentryresult.exception.ApplicationListEntryResultError; +import uk.gov.hmcts.appregister.common.entity.DataAudit; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.data.ResolutionCodeTestData; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; + +class ApplicationEntryResultControllerBulkDeleteTest + extends AbstractApplicationEntryResultCrudTest { + + @Test + void givenValidRequest_whenBulkDelete_thenDeletesAllResultsAndWritesSingleBulkAudit() + throws Exception { + var list = createAndSaveList(OPEN); + var firstEntry = createEntry(list); + var secondEntry = createEntry(list); + persistance.save(firstEntry); + persistance.save(secondEntry); + + var firstResult = + createAndSaveResolution(firstEntry, new ResolutionCodeTestData().someComplete()); + var secondResult = + createAndSaveResolution(secondEntry, new ResolutionCodeTestData().someComplete()); + + dataAuditRepository.deleteAll(); + + deleteBulkResult( + getToken(), + new BulkDeleteResultsDto() + .results( + List.of( + requestItem( + list.getUuid(), + firstEntry.getUuid(), + firstResult.getUuid()), + requestItem( + list.getUuid(), + secondEntry.getUuid(), + secondResult.getUuid())))) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + + assertThat(appListEntryResolutionRepository.findByUuid(firstResult.getUuid())).isEmpty(); + assertThat(appListEntryResolutionRepository.findByUuid(secondResult.getUuid())).isEmpty(); + + var bulkAuditRow = + awaitBulkDeleteAuditRow( + firstResult.getUuid(), secondResult.getUuid(), firstEntry.getUuid()); + assertThat(bulkAuditRow.getEventName()) + .isEqualTo( + AppListEntryResultAuditOperation.BULK_DELETE_APP_LIST_ENTRY_RESULT + .getEventName()); + assertThat(valueOrClob(bulkAuditRow.getNewValue(), bulkAuditRow.getNewClobValue())) + .contains(firstResult.getUuid().toString(), secondResult.getUuid().toString()); + assertThat(noPerResultDeleteAuditRows()).isTrue(); + } + + @Test + void givenInvalidRequest_whenBulkDelete_thenRollsBackEverything() throws Exception { + var list = createAndSaveList(OPEN); + var entry = createEntry(list); + persistance.save(entry); + + var result = createAndSaveResolution(entry, new ResolutionCodeTestData().someComplete()); + + var response = + deleteBulkResult( + getToken(), + new BulkDeleteResultsDto() + .results( + List.of( + requestItem( + list.getUuid(), + entry.getUuid(), + result.getUuid()), + requestItem( + list.getUuid(), + entry.getUuid(), + UUID.randomUUID())))); + + response.then().statusCode(HttpStatus.NOT_FOUND.value()); + assertEquals( + ApplicationListEntryResultError.APPLICATION_ENTRY_RESULT_DOES_NOT_EXIST.getCode(), + response); + + assertThat(appListEntryResolutionRepository.findByUuid(result.getUuid())).isPresent(); + assertThat( + dataAuditRepository.findAll().stream() + .noneMatch( + row -> + AppListEntryResultAuditOperation + .BULK_DELETE_APP_LIST_ENTRY_RESULT + .getEventName() + .equals(row.getEventName()))) + .isTrue(); + } + + private BulkDeleteResultItemDto requestItem(UUID listId, UUID entryId, UUID resultId) { + return new BulkDeleteResultItemDto().listId(listId).entryId(entryId).resultId(resultId); + } + + private DataAudit awaitBulkDeleteAuditRow(UUID resultId, UUID resultId1, UUID entryId) { + for (int attempt = 0; attempt < 10; attempt++) { + Optional auditRow = + dataAuditRepository.findAll().stream() + .filter( + audit -> + TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS.equals( + audit.getTableName()) + && "bulk_delete_results" + .equals(audit.getColumnName()) + && AppListEntryResultAuditOperation + .BULK_DELETE_APP_LIST_ENTRY_RESULT + .getEventName() + .equals(audit.getEventName())) + .filter( + audit -> { + var auditValue = + valueOrClob( + audit.getNewValue(), + audit.getNewClobValue()); + return auditValue.contains(resultId.toString()) + && auditValue.contains(resultId1.toString()) + && auditValue.contains(entryId.toString()); + }) + .findFirst(); + if (auditRow.isPresent()) { + return auditRow.get(); + } + } + + throw new AssertionError("Expected a bulk delete audit row with deleted result details"); + } + + private boolean noPerResultDeleteAuditRows() { + return dataAuditRepository.findAll().stream() + .noneMatch( + auditRow -> + AppListEntryResultAuditOperation.DELETE_APP_LIST_ENTRY_RESULT + .getEventName() + .equals(auditRow.getEventName()) + && TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS.equals( + auditRow.getTableName())); + } + + private String valueOrClob(String value, String clobValue) { + return value != null ? value : clobValue; + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/appregister/testutils/client/RestAssuredClient.java b/src/integrationTest/java/uk/gov/hmcts/appregister/testutils/client/RestAssuredClient.java index 230c11a87..049943299 100644 --- a/src/integrationTest/java/uk/gov/hmcts/appregister/testutils/client/RestAssuredClient.java +++ b/src/integrationTest/java/uk/gov/hmcts/appregister/testutils/client/RestAssuredClient.java @@ -366,6 +366,15 @@ public Response executeDeleteRequest(URL url, TokenAndJwksKey token) { .andReturn(); } + public Response executeDeleteRequest(URL url, TokenAndJwksKey token, Object object) { + return given().body(object) + .header("Authorization", "Bearer " + token.getToken()) + .header("Content-Type", "application/vnd.hmcts.appreg.v1+json") + .header("traceparent", DEFAULT_TRACE_ID) + .delete(url) + .andReturn(); + } + /** * deletes a request builder that can be used to make requests against the application with an * If-Match header. diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/exception/AppListEntryError.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/exception/AppListEntryError.java index aa6150502..070c9f3f1 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/exception/AppListEntryError.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/exception/AppListEntryError.java @@ -199,7 +199,7 @@ public enum AppListEntryError implements ErrorCodeEnum { PAYMENT_REFERENCE_TOO_LONG( DefaultErrorDetail.create( HttpStatus.BAD_REQUEST, - "paymentReference must not be longer than 100 characters", + "paymentReference must not be longer than 15 characters", "ALE-40")), OFFSITE_FEE_REQUIRED( diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentry/validator/BulkUpdateFeesValidator.java b/src/main/java/uk/gov/hmcts/appregister/applicationentry/validator/BulkUpdateFeesValidator.java index bb18a6f63..34839667c 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentry/validator/BulkUpdateFeesValidator.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentry/validator/BulkUpdateFeesValidator.java @@ -32,7 +32,7 @@ public class BulkUpdateFeesValidator implements Validator { - private static final int PAYMENT_REFERENCE_MAX_LENGTH = 100; + private static final int PAYMENT_REFERENCE_MAX_LENGTH = 15; private final ApplicationListRepository applicationListRepository; private final ApplicationListEntryRepository applicationListEntryRepository; diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java index 685b0a484..152776be6 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/AppListEntryResultAuditOperation.java @@ -9,6 +9,8 @@ @Getter public enum AppListEntryResultAuditOperation implements AuditOperation { DELETE_APP_LIST_ENTRY_RESULT("Delete Application List Entry Result", CrudEnum.DELETE), + BULK_DELETE_APP_LIST_ENTRY_RESULT( + "Delete Multiple Application List Entry Results", CrudEnum.DELETE), CREATE_APP_LIST_ENTRY_RESULT("Create Application List Entry Result", CrudEnum.CREATE), BULK_CREATE_APP_LIST_ENTRY_RESULT("Result Multiple Applications", CrudEnum.CREATE), UPDATE_APP_LIST_ENTRY_RESULT("Update Application List Entry Result", CrudEnum.UPDATE), diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAudit.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAudit.java new file mode 100644 index 000000000..906fa3692 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAudit.java @@ -0,0 +1,92 @@ +package uk.gov.hmcts.appregister.applicationentryresult.audit; + +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import uk.gov.hmcts.appregister.audit.listener.diff.Auditable; +import uk.gov.hmcts.appregister.audit.listener.diff.AuditableData; +import uk.gov.hmcts.appregister.audit.listener.diff.BulkAuditFormatting; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; + +public record BulkDeleteApplicationEntryResultAudit( + Long resultId, List listIds, List entryIds, int resultCount, String results) + implements Auditable { + + private static final String TABLE_NAME = TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS; + private static final String LIST_IDS_FIELD = "bulk_delete_results_list_ids"; + private static final String ENTRY_IDS_FIELD = "bulk_delete_results_entry_ids"; + private static final String RESULT_COUNT_FIELD = "bulk_delete_results_count"; + private static final String RESULTS_FIELD = "bulk_delete_results"; + + @Override + public Long getId() { + return resultId; + } + + @Override + public List extractAuditData(CrudEnum crudEnum) { + return List.of( + new AuditableData( + TABLE_NAME, + LIST_IDS_FIELD, + BulkAuditFormatting.formatSortedUuidArray(listIds)), + new AuditableData( + TABLE_NAME, + ENTRY_IDS_FIELD, + BulkAuditFormatting.formatSortedUuidArray(entryIds)), + new AuditableData(TABLE_NAME, RESULT_COUNT_FIELD, Integer.toString(resultCount)), + new AuditableData(TABLE_NAME, RESULTS_FIELD, results)); + } + + public static String formatRequestedResults(List requestedResults) { + return requestedResults.stream() + .map( + requestedResult -> + "{\"listId\":\"%s\",\"entryId\":\"%s\",\"resultId\":\"%s\"}" + .formatted( + requestedResult.getListId(), + requestedResult.getEntryId(), + requestedResult.getResultId())) + .collect(Collectors.joining(",", "[", "]")); + } + + public static String formatDeletedResults(List deletedResults) { + return deletedResults.stream() + .sorted( + Comparator.comparing( + (AppListEntryResolution resolution) -> + resolution + .getApplicationList() + .getApplicationList() + .getId()) + .thenComparing( + resolution -> resolution.getApplicationList().getId()) + .thenComparing(AppListEntryResolution::getId)) + .map( + resolution -> + ("{\"listId\":\"%s\",\"entryId\":\"%s\",\"resultId\":\"%s\"," + + "\"sequenceNumber\":%s,\"resultCode\":\"%s\"," + + "\"wording\":\"%s\",\"officer\":\"%s\"}") + .formatted( + resolution + .getApplicationList() + .getApplicationList() + .getUuid(), + resolution.getApplicationList().getUuid(), + resolution.getUuid(), + resolution.getApplicationList().getSequenceNumber(), + BulkAuditFormatting.escape( + resolution + .getResolutionCode() + .getResultCode()), + BulkAuditFormatting.escape( + resolution.getResolutionWording()), + BulkAuditFormatting.escape( + resolution.getResolutionOfficer()))) + .collect(Collectors.joining(",", "[", "]")); + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultController.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultController.java index b59338e7c..9858ab7da 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultController.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultController.java @@ -25,6 +25,7 @@ import uk.gov.hmcts.appregister.common.security.RoleNames; import uk.gov.hmcts.appregister.common.util.PagingWrapper; import uk.gov.hmcts.appregister.generated.api.ApplicationListEntryResultsApi; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.ResultGetDto; @@ -78,6 +79,13 @@ public ResponseEntity> bulkResultEntries(BulkResultDto bulkRe .body(createdResults); } + @Override + @PreAuthorize(RoleNames.USER_ROLE_OR_ADMIN_ROLE_RESTRICTION) + public ResponseEntity bulkDeleteResultEntries(BulkDeleteResultsDto bulkDeleteResultsDto) { + service.bulkDelete(bulkDeleteResultsDto); + return ResponseEntity.noContent().build(); + } + /** * Deletes an Application List Entry Result. * diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultService.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultService.java index c8b8ac2a3..8cf0c57c2 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultService.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultService.java @@ -8,6 +8,7 @@ import uk.gov.hmcts.appregister.applicationentryresult.model.PayloadGetEntryResultInList; import uk.gov.hmcts.appregister.common.concurrency.MatchResponse; import uk.gov.hmcts.appregister.common.util.PagingWrapper; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.ResultGetDto; @@ -19,6 +20,8 @@ public interface ApplicationEntryResultService { void delete(ListEntryResultDeleteArgs args); + void bulkDelete(BulkDeleteResultsDto bulkDeleteResultsDto); + /** * Creates a new application list entry result. * diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java index 6f6acf8b2..4bb941d1d 100644 --- a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImpl.java @@ -14,6 +14,7 @@ import uk.gov.hmcts.appregister.applicationentryresult.audit.AppListEntryResultAuditOperation; import uk.gov.hmcts.appregister.applicationentryresult.audit.ApplicationListEntryResultAudit; import uk.gov.hmcts.appregister.applicationentryresult.audit.BulkCreateApplicationEntryResultAudit; +import uk.gov.hmcts.appregister.applicationentryresult.audit.BulkDeleteApplicationEntryResultAudit; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultEntityMapper; import uk.gov.hmcts.appregister.applicationentryresult.mapper.ApplicationListEntryResultMapper; import uk.gov.hmcts.appregister.applicationentryresult.model.ListEntryResultDeleteArgs; @@ -26,6 +27,7 @@ import uk.gov.hmcts.appregister.applicationentryresult.validator.ApplicationEntryResultGetValidator; import uk.gov.hmcts.appregister.applicationentryresult.validator.ApplicationEntryResultUpdateValidator; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultCreationValidator; +import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultDeletionValidator; import uk.gov.hmcts.appregister.audit.model.AuditableResult; import uk.gov.hmcts.appregister.audit.service.AuditOperationService; import uk.gov.hmcts.appregister.common.concurrency.MatchResponse; @@ -40,6 +42,7 @@ import uk.gov.hmcts.appregister.common.security.UserProvider; import uk.gov.hmcts.appregister.common.util.BeanUtil; import uk.gov.hmcts.appregister.common.util.PagingWrapper; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.ResultGetDto; @@ -60,6 +63,8 @@ public class ApplicationEntryResultServiceImpl implements ApplicationEntryResult private final ApplicationEntryResultCreationValidator creationValidator; private final ApplicationEntryResultUpdateValidator updateValidator; private final ApplicationEntryResultGetValidator applicationListGetValidator; + private final BulkApplicationEntryResultDeletionValidator + bulkApplicationEntryResultDeletionValidator; private final BulkApplicationEntryResultCreationValidator bulkApplicationEntryResultCreationValidator; @@ -114,6 +119,67 @@ public void delete(ListEntryResultDeleteArgs args) { }); } + @Override + @Transactional + public void bulkDelete(BulkDeleteResultsDto bulkDeleteResultsDto) { + bulkApplicationEntryResultDeletionValidator.validate( + bulkDeleteResultsDto, + (request, success) -> { + var deletedResults = + success.getResults().stream() + .map( + item -> + BeanUtil.copyBean( + item.validationSuccess() + .getAppListEntryResult())) + .toList(); + var bulkAuditRequest = + new BulkDeleteApplicationEntryResultAudit( + deletedResults.isEmpty() + ? null + : deletedResults.getFirst().getId(), + success.getResults().stream() + .map(item -> item.args().listId()) + .toList(), + success.getResults().stream() + .map(item -> item.args().entryId()) + .toList(), + deletedResults.size(), + BulkDeleteApplicationEntryResultAudit.formatRequestedResults( + request.getResults())); + var bulkAuditResult = + new BulkDeleteApplicationEntryResultAudit( + deletedResults.isEmpty() + ? null + : deletedResults.getFirst().getId(), + success.getResults().stream() + .map(item -> item.args().listId()) + .toList(), + success.getResults().stream() + .map(item -> item.args().entryId()) + .toList(), + deletedResults.size(), + BulkDeleteApplicationEntryResultAudit.formatDeletedResults( + deletedResults)); + + auditService.processAudit( + bulkAuditRequest, + AppListEntryResultAuditOperation.BULK_DELETE_APP_LIST_ENTRY_RESULT, + ignored -> { + repository.deleteAllInBatch( + success.getResults().stream() + .map( + item -> + item.validationSuccess() + .getAppListEntryResult()) + .toList()); + entityManager.flush(); + return Optional.of(new AuditableResult<>(null, bulkAuditResult)); + }); + return null; + }); + } + @Override @Transactional public MatchResponse create( diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionSuccess.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionSuccess.java new file mode 100644 index 000000000..54b536315 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionSuccess.java @@ -0,0 +1,10 @@ +package uk.gov.hmcts.appregister.applicationentryresult.validator; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; + +@Getter +public class BulkApplicationEntryResultDeletionSuccess { + private final List results = new ArrayList<>(); +} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatedItem.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatedItem.java new file mode 100644 index 000000000..890c999c8 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatedItem.java @@ -0,0 +1,6 @@ +package uk.gov.hmcts.appregister.applicationentryresult.validator; + +import uk.gov.hmcts.appregister.applicationentryresult.model.ListEntryResultDeleteArgs; + +public record BulkApplicationEntryResultDeletionValidatedItem( + ListEntryResultDeleteArgs args, ListEntryResultDeleteValidationSuccess validationSuccess) {} diff --git a/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidator.java b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidator.java new file mode 100644 index 000000000..6d823a2c8 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidator.java @@ -0,0 +1,173 @@ +package uk.gov.hmcts.appregister.applicationentryresult.validator; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import uk.gov.hmcts.appregister.applicationentryresult.exception.ApplicationListEntryResultError; +import uk.gov.hmcts.appregister.applicationentryresult.model.ListEntryResultDeleteArgs; +import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.ApplicationList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.model.EntryToList; +import uk.gov.hmcts.appregister.common.entity.repository.AppListEntryResolutionRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListEntryRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListRepository; +import uk.gov.hmcts.appregister.common.exception.AppRegistryException; +import uk.gov.hmcts.appregister.common.validator.Validator; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; + +@Component +@RequiredArgsConstructor +public class BulkApplicationEntryResultDeletionValidator + implements Validator { + + private final ApplicationListRepository applicationListRepository; + private final ApplicationListEntryRepository applicationListEntryRepository; + private final AppListEntryResolutionRepository appListEntryResolutionRepository; + + @Override + public void validate(BulkDeleteResultsDto validatable) { + validate(validatable, (request, success) -> null); + } + + @Override + public R validate( + BulkDeleteResultsDto validatable, + BiFunction + validateSuccess) { + var results = + Optional.ofNullable(validatable) + .map(BulkDeleteResultsDto::getResults) + .map(ArrayList::new) + .orElseGet(ArrayList::new); + + if (results.isEmpty()) { + throw new AppRegistryException( + ApplicationListError.ENTRY_NOT_PROVIDED, "No result delete items provided"); + } + + validateNoDuplicateItems(results); + + var listIds = collectDistinct(results, BulkDeleteResultItemDto::getListId); + var entryIds = collectDistinct(results, BulkDeleteResultItemDto::getEntryId); + var resultIds = collectDistinct(results, BulkDeleteResultItemDto::getResultId); + + var listsByUuid = + applicationListRepository.findByUuidIncludingDeleteIn(listIds).stream() + .collect(Collectors.toMap(ApplicationList::getUuid, Function.identity())); + var entriesByUuid = + applicationListEntryRepository.findByUuidIncludingDeleteIn(entryIds).stream() + .collect( + Collectors.toMap( + ApplicationListEntry::getUuid, Function.identity())); + var activeEntriesByKey = + applicationListEntryRepository + .findActiveByUuidsAndApplicationListUuids(entryIds, listIds) + .stream() + .collect( + Collectors.toMap( + entry -> + new EntryToList( + entry.getUuid(), + entry.getApplicationList().getUuid()), + Function.identity())); + var resultsByUuid = + appListEntryResolutionRepository.findByUuidIn(resultIds).stream() + .collect( + Collectors.toMap( + AppListEntryResolution::getUuid, Function.identity())); + + var success = new BulkApplicationEntryResultDeletionSuccess(); + for (var result : results) { + var args = + new ListEntryResultDeleteArgs( + result.getListId(), result.getEntryId(), result.getResultId()); + var applicationList = listsByUuid.get(args.listId()); + if (applicationList == null) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_LIST_DOES_NOT_EXIST, + "The application list does not exist %s".formatted(args.listId())); + } + if (applicationList.isDeleted() || !applicationList.isOpen()) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_LIST_STATE_IS_INCORRECT, + "The application list id %s is not in the correct state or the application list is deleted %s" + .formatted(args.listId(), applicationList.getStatus())); + } + + if (!entriesByUuid.containsKey(args.entryId())) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_DOES_NOT_EXIST, + "No application list entry exists %s".formatted(args.entryId())); + } + + var activeEntryKey = new EntryToList(args.entryId(), args.listId()); + var applicationListEntry = activeEntriesByKey.get(activeEntryKey); + if (applicationListEntry == null) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_NOT_WITHIN_LIST, + "The application list entry %s does not belong to list %s" + .formatted(args.entryId(), args.listId())); + } + + var appListEntryResult = resultsByUuid.get(args.resultId()); + if (appListEntryResult == null) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_RESULT_DOES_NOT_EXIST, + "No application list entry result was found for UUID '%s'" + .formatted(args.resultId())); + } + + if (!args.entryId().equals(appListEntryResult.getApplicationList().getUuid())) { + throw new AppRegistryException( + ApplicationListEntryResultError.APPLICATION_ENTRY_RESULT_NOT_WITHIN_ENTRY, + ("No application list entry result was found for UUID '%s' that" + + " belongs to the specified entry") + .formatted(args.resultId())); + } + + success.getResults() + .add( + new BulkApplicationEntryResultDeletionValidatedItem( + args, + new ListEntryResultDeleteValidationSuccess( + null, + null, + applicationList, + applicationListEntry, + appListEntryResult))); + } + + return validateSuccess.apply(validatable, success); + } + + private List collectDistinct( + List results, Function mapper) { + return results.stream().map(mapper).distinct().toList(); + } + + private void validateNoDuplicateItems(List results) { + Set uniqueKeys = new HashSet<>(); + for (var result : results) { + var key = + "%s|%s|%s" + .formatted( + result.getListId(), result.getEntryId(), result.getResultId()); + if (!uniqueKeys.add(key)) { + throw new AppRegistryException( + ApplicationListError.ENTRY_IDS_MUST_BE_UNIQUE, + "Duplicate result delete items are not allowed"); + } + } + } +} diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/AppListEntryFeeStatus.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/AppListEntryFeeStatus.java index b48978643..fcb0249b2 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/AppListEntryFeeStatus.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/AppListEntryFeeStatus.java @@ -58,7 +58,7 @@ public class AppListEntryFeeStatus implements Changeable, Accountable, Versionab @JoinColumn(name = "alefs_ale_id", nullable = false) private ApplicationListEntry appListEntry; - @Column(name = "alefs_payment_reference") + @Column(name = "alefs_payment_reference", length = 15) @Audit(action = {CrudEnum.CREATE, CrudEnum.DELETE}) private String alefsPaymentReference; diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryResolutionRepository.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryResolutionRepository.java index 2e46ca32e..a4b1c8785 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryResolutionRepository.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/AppListEntryResolutionRepository.java @@ -6,6 +6,7 @@ import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; @@ -66,6 +67,14 @@ Optional findByUuidAndApplicationList_Uuid( Optional findByUuid(UUID resolutionUuid); + @EntityGraph( + attributePaths = { + "applicationList", + "applicationList.applicationList", + "resolutionCode" + }) + List findByUuidIn(Collection resolutionUuids); + /** * Finds an AppListEntryResolution by the UUID of the associated application list entry. * diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java index a19ce28f6..083462004 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListEntryRepository.java @@ -868,6 +868,15 @@ List findByApplicationListIdsForPrinting( """) Optional findByUuidIncludingDelete(UUID entryId); + @Query( + """ + SELECT ale + FROM ApplicationListEntry ale + WHERE ale.uuid IN :entryIds + """) + List findByUuidIncludingDeleteIn( + @Param("entryIds") Collection entryIds); + /** * Finds all entities with the given IDs, within the associated list. * @@ -966,6 +975,19 @@ int bulkMoveByUuidAndSourceList( Optional findActiveByUuidAndApplicationListUuid( @Param("entryUuid") UUID entryUuid, @Param("listUuid") UUID listUuid); + @Query( + """ + SELECT ale + FROM ApplicationListEntry ale + WHERE ale.uuid IN :entryUuids + AND ale.applicationList.uuid IN :listUuids + AND (ale.deleted IS NULL OR ale.deleted <> 'Y') + AND (ale.applicationList.deleted IS NULL OR ale.applicationList.deleted <> 'Y') + """) + List findActiveByUuidsAndApplicationListUuids( + @Param("entryUuids") Collection entryUuids, + @Param("listUuids") Collection listUuids); + /** * Soft-deletes an application list entry by UUID. * diff --git a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListRepository.java b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListRepository.java index 5be74d46f..abb483f4c 100644 --- a/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListRepository.java +++ b/src/main/java/uk/gov/hmcts/appregister/common/entity/repository/ApplicationListRepository.java @@ -69,6 +69,14 @@ public interface ApplicationListRepository extends JpaRepository findByUuidIncludingDelete(UUID id); + @Query( + """ + SELECT al + FROM ApplicationList al + WHERE al.uuid IN :ids + """) + List findByUuidIncludingDeleteIn(@Param("ids") Collection ids); + @Query( """ SELECT al diff --git a/src/main/resources/openapi/components/schemas/application-codes/application-code-get-detail-dto.yaml b/src/main/resources/openapi/components/schemas/application-codes/application-code-get-detail-dto.yaml index df805e8e5..48127567f 100644 --- a/src/main/resources/openapi/components/schemas/application-codes/application-code-get-detail-dto.yaml +++ b/src/main/resources/openapi/components/schemas/application-codes/application-code-get-detail-dto.yaml @@ -38,6 +38,7 @@ properties: respondents to be created and linked. feeReference: type: string + maxLength: 12 nullable: true description: A short reference code that identifies a Fee. example: "CO5.2" @@ -70,6 +71,7 @@ properties: offsiteFeeReference: type: string + maxLength: 12 nullable: true description: A short reference code that identifies a offsite Fee. example: "CO5.2" diff --git a/src/main/resources/openapi/components/schemas/application-codes/application-code-get-summary-dto.yaml b/src/main/resources/openapi/components/schemas/application-codes/application-code-get-summary-dto.yaml index d343b149c..6af99fbe2 100644 --- a/src/main/resources/openapi/components/schemas/application-codes/application-code-get-summary-dto.yaml +++ b/src/main/resources/openapi/components/schemas/application-codes/application-code-get-summary-dto.yaml @@ -35,6 +35,7 @@ properties: respondents to be created and linked. feeReference: type: string + maxLength: 12 nullable: true description: A short reference code that identifies a Fee. example: "CO5.2" @@ -65,6 +66,7 @@ properties: description: Descriptive text for the fee. offsiteFeeReference: type: string + maxLength: 12 nullable: true description: A short reference code that identifies a offsite Fee. example: "CO1.1" diff --git a/src/main/resources/openapi/components/schemas/application-list-entries/bulk-fee-details-dto.yaml b/src/main/resources/openapi/components/schemas/application-list-entries/bulk-fee-details-dto.yaml index a581027cf..30726ce24 100644 --- a/src/main/resources/openapi/components/schemas/application-list-entries/bulk-fee-details-dto.yaml +++ b/src/main/resources/openapi/components/schemas/application-list-entries/bulk-fee-details-dto.yaml @@ -21,7 +21,7 @@ properties: type: string description: Optional reference used to identify the payment. example: "PAY-001" - maxLength: 100 + maxLength: 15 minLength: 1 hasOffsiteFee: diff --git a/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-result-item-dto.yaml b/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-result-item-dto.yaml new file mode 100644 index 000000000..a2dce31b0 --- /dev/null +++ b/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-result-item-dto.yaml @@ -0,0 +1,22 @@ +type: object +additionalProperties: false +required: + - listId + - entryId + - resultId +properties: + listId: + type: string + format: uuid + description: Public identifier of the Application List. + example: "123e4567-e89b-12d3-a456-426655440000" + entryId: + type: string + format: uuid + description: Public identifier of the Application List Entry. + example: "123e4567-e89b-12d3-a456-426655440100" + resultId: + type: string + format: uuid + description: Public identifier of the Application List Entry Result. + example: "123e4567-e89b-12d3-a456-426655440200" diff --git a/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-results-dto.yaml b/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-results-dto.yaml new file mode 100644 index 000000000..36d26cd62 --- /dev/null +++ b/src/main/resources/openapi/components/schemas/application-list-entry-results/bulk-delete-results-dto.yaml @@ -0,0 +1,14 @@ +type: object +description: Delete multiple Application List Entry Results atomically. +additionalProperties: false +required: + - results +properties: + results: + type: array + minItems: 1 + description: > + Result delete requests to execute. Each item is validated using the same rules as the + single-result delete endpoint. If any item is invalid, no results are deleted. + items: + $ref: './bulk-delete-result-item-dto.yaml' diff --git a/src/main/resources/openapi/paths/application-list-entry-results/entries-bulk-result.yaml b/src/main/resources/openapi/paths/application-list-entry-results/entries-bulk-result.yaml index 8341d73b1..026ba490a 100644 --- a/src/main/resources/openapi/paths/application-list-entry-results/entries-bulk-result.yaml +++ b/src/main/resources/openapi/paths/application-list-entry-results/entries-bulk-result.yaml @@ -33,3 +33,33 @@ post: "404": { $ref: "../../components/responses/not-found-error.yaml" } "409": { $ref: "../../components/responses/conflict-error.yaml" } "500": { $ref: "../../components/responses/internal-server-error.yaml" } + +delete: + operationId: bulkDeleteResultEntries + tags: [ application-list-entry-results ] + summary: Delete multiple Application List Entry Results. + description: > + Atomically deletes the specified Application List Entry Results. Each item is validated using + the same rules as the single delete endpoint. If any item cannot be deleted, no Results are + removed. + requestBody: + required: true + content: + application/vnd.hmcts.appreg.v1+json: + schema: + $ref: "../../components/schemas/application-list-entry-results/bulk-delete-results-dto.yaml" + responses: + "204": + description: Results deleted for all supplied items. + headers: + Vary: + description: Response varies by Accept for media-type versioning. + schema: + type: string + example: Accept + "400": { $ref: "../../components/responses/bad-request-error.yaml" } + "401": { $ref: "../../components/responses/unauthorized-error.yaml" } + "403": { $ref: "../../components/responses/forbidden-error.yaml" } + "404": { $ref: "../../components/responses/not-found-error.yaml" } + "409": { $ref: "../../components/responses/conflict-error.yaml" } + "500": { $ref: "../../components/responses/internal-server-error.yaml" } diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAuditTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAuditTest.java new file mode 100644 index 000000000..b6ed42233 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/audit/BulkDeleteApplicationEntryResultAuditTest.java @@ -0,0 +1,77 @@ +package uk.gov.hmcts.appregister.applicationentryresult.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.ApplicationList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.ResolutionCode; +import uk.gov.hmcts.appregister.common.entity.TableNames; +import uk.gov.hmcts.appregister.common.enumeration.CrudEnum; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; + +class BulkDeleteApplicationEntryResultAuditTest { + + @Test + void extractAuditData_includesSummaryFields() { + var listId = UUID.randomUUID(); + var entryId = UUID.randomUUID(); + var audit = + new BulkDeleteApplicationEntryResultAudit( + 10L, List.of(listId), List.of(entryId), 1, "[{\"resultId\":\"x\"}]"); + + var auditData = audit.extractAuditData(CrudEnum.DELETE); + + assertThat(auditData) + .anyMatch( + row -> + TableNames.APPLICATION_LIST_ENTRY_RESOLUTIONS.equals( + row.getTableName()) + && "bulk_delete_results_count".equals(row.getFieldName()) + && "1".equals(row.getValue())); + } + + @Test + void formatHelpers_renderExpectedValues() { + var requestedItem = + new BulkDeleteResultItemDto() + .listId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .entryId(UUID.fromString("22222222-2222-2222-2222-222222222222")) + .resultId(UUID.fromString("33333333-3333-3333-3333-333333333333")); + + assertThat( + BulkDeleteApplicationEntryResultAudit.formatRequestedResults( + List.of(requestedItem))) + .contains( + requestedItem.getListId().toString(), + requestedItem.getResultId().toString()); + + var list = new ApplicationList(); + list.setId(1L); + list.setUuid(requestedItem.getListId()); + var entry = new ApplicationListEntry(); + entry.setId(2L); + entry.setUuid(requestedItem.getEntryId()); + entry.setSequenceNumber((short) 7); + entry.setApplicationList(list); + var code = new ResolutionCode(); + code.setResultCode("CODE\"1"); + var resolution = new AppListEntryResolution(); + resolution.setId(3L); + resolution.setUuid(requestedItem.getResultId()); + resolution.setApplicationList(entry); + resolution.setResolutionCode(code); + resolution.setResolutionWording("Wording\\\"1"); + resolution.setResolutionOfficer("Officer\\\"1"); + + assertThat(BulkDeleteApplicationEntryResultAudit.formatDeletedResults(List.of(resolution))) + .contains( + "\"sequenceNumber\":7", + "CODE\\\"1", + "Wording\\\\\\\"1", + "Officer\\\\\\\"1"); + } +} diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultControllerTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultControllerTest.java index a3aa82828..35153c97e 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultControllerTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/controller/ApplicationEntryResultControllerTest.java @@ -23,6 +23,7 @@ import uk.gov.hmcts.appregister.common.concurrency.MatchResponse; import uk.gov.hmcts.appregister.common.mapper.PageableMapper; import uk.gov.hmcts.appregister.common.util.PagingWrapper; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.ResultGetDto; @@ -74,6 +75,17 @@ void bulkResultEntries_delegatesAndReturnsOk() { assertThat(actual.getBody()).isSameAs(body); } + @Test + void bulkDeleteResultEntries_delegatesAndReturnsNoContent() { + var request = new BulkDeleteResultsDto(); + doNothing().when(service).bulkDelete(any()); + + ResponseEntity actual = controller.bulkDeleteResultEntries(request); + + verify(service).bulkDelete(any()); + assertThat(actual.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + @Test void deleteApplicationListEntryResult_delegatesAndReturnsNoContent() { UUID listId = UUID.randomUUID(); diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java index b5a99d38d..007ba54f4 100644 --- a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/service/ApplicationEntryResultServiceImplTest.java @@ -3,6 +3,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -45,6 +46,9 @@ import uk.gov.hmcts.appregister.applicationentryresult.validator.ApplicationEntryResultUpdateValidator; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultCreationSuccess; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultCreationValidator; +import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultDeletionSuccess; +import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultDeletionValidatedItem; +import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultDeletionValidator; import uk.gov.hmcts.appregister.applicationentryresult.validator.BulkApplicationEntryResultValidatedItem; import uk.gov.hmcts.appregister.applicationentryresult.validator.ListEntryResultCreateValidationSuccess; import uk.gov.hmcts.appregister.applicationentryresult.validator.ListEntryResultDeleteValidationSuccess; @@ -76,6 +80,8 @@ import uk.gov.hmcts.appregister.common.service.BusinessDateProvider; import uk.gov.hmcts.appregister.common.template.wording.WordingTemplateSentence; import uk.gov.hmcts.appregister.common.util.PagingWrapper; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; import uk.gov.hmcts.appregister.generated.model.BulkResultDto; import uk.gov.hmcts.appregister.generated.model.ResultCreateDto; import uk.gov.hmcts.appregister.generated.model.ResultGetDto; @@ -132,6 +138,8 @@ class ApplicationEntryResultServiceImplTest { resolutionCodeRepository, businessDateProvider); + @Mock private BulkApplicationEntryResultDeletionValidator bulkDeleteResultEntry; + @Spy private DummyBulkApplicationEntryResultGetValidator bulkResultEntry = new DummyBulkApplicationEntryResultGetValidator( @@ -159,6 +167,7 @@ void setUp() { creationValidator, updateValidator, getValidator, + bulkDeleteResultEntry, bulkResultEntry, matchService, auditOperationService, @@ -273,6 +282,93 @@ void delete_validArgs_deletesEntryResult() { verify(appListEntryResolutionRepository).delete(any(AppListEntryResolution.class)); } + @Test + void bulkDelete_validArgs_deletesEntryResultsInBatch() { + var firstResult = createResolution(11L, 101L, 1001L, "CODE1", "wording1", "officer1"); + var secondResult = createResolution(12L, 102L, 1002L, "CODE2", "wording2", "officer2"); + + var bulkDeleteSuccess = new BulkApplicationEntryResultDeletionSuccess(); + bulkDeleteSuccess + .getResults() + .add( + new BulkApplicationEntryResultDeletionValidatedItem( + new ListEntryResultDeleteArgs( + firstResult + .getApplicationList() + .getApplicationList() + .getUuid(), + firstResult.getApplicationList().getUuid(), + firstResult.getUuid()), + new ListEntryResultDeleteValidationSuccess( + with(""), + firstResult.getResolutionCode(), + firstResult.getApplicationList().getApplicationList(), + firstResult.getApplicationList(), + firstResult))); + bulkDeleteSuccess + .getResults() + .add( + new BulkApplicationEntryResultDeletionValidatedItem( + new ListEntryResultDeleteArgs( + secondResult + .getApplicationList() + .getApplicationList() + .getUuid(), + secondResult.getApplicationList().getUuid(), + secondResult.getUuid()), + new ListEntryResultDeleteValidationSuccess( + with(""), + secondResult.getResolutionCode(), + secondResult.getApplicationList().getApplicationList(), + secondResult.getApplicationList(), + secondResult))); + doAnswer( + invocation -> { + BiFunction< + BulkDeleteResultsDto, + BulkApplicationEntryResultDeletionSuccess, + Object> + validateSuccess = invocation.getArgument(1); + return validateSuccess.apply( + invocation.getArgument(0), bulkDeleteSuccess); + }) + .when(bulkDeleteResultEntry) + .validate(any(BulkDeleteResultsDto.class), any()); + + var request = + new BulkDeleteResultsDto() + .results( + List.of( + new BulkDeleteResultItemDto() + .listId( + firstResult + .getApplicationList() + .getApplicationList() + .getUuid()) + .entryId(firstResult.getApplicationList().getUuid()) + .resultId(firstResult.getUuid()), + new BulkDeleteResultItemDto() + .listId( + secondResult + .getApplicationList() + .getApplicationList() + .getUuid()) + .entryId( + secondResult.getApplicationList().getUuid()) + .resultId(secondResult.getUuid()))); + + service.bulkDelete(request); + + verify(appListEntryResolutionRepository) + .deleteAllInBatch(List.of(firstResult, secondResult)); + verify(entityManager).flush(); + verify(auditOperationService) + .processAudit( + any(), + eq(AppListEntryResultAuditOperation.BULK_DELETE_APP_LIST_ENTRY_RESULT), + any()); + } + @Test void search_validArgs_returnEntryResult() { PayloadGetEntryResultInList payloadGetEntryResultInList = @@ -677,4 +773,35 @@ void update_validArgs_returnsUpdatedEntryResult() { verify(entityManager).flush(); verify(entityManager).refresh(existing); } + + private AppListEntryResolution createResolution( + Long resultId, + Long entryId, + Long listId, + String resultCode, + String wording, + String officer) { + var list = new ApplicationList(); + list.setId(listId); + list.setUuid(UUID.randomUUID()); + + var entry = new ApplicationListEntry(); + entry.setId(entryId); + entry.setUuid(UUID.randomUUID()); + entry.setSequenceNumber(entryId.shortValue()); + entry.setApplicationList(list); + + var code = new ResolutionCode(); + code.setResultCode(resultCode); + + var resolution = new AppListEntryResolution(); + resolution.setId(resultId); + resolution.setVersion(1L); + resolution.setUuid(UUID.randomUUID()); + resolution.setApplicationList(entry); + resolution.setResolutionCode(code); + resolution.setResolutionWording(wording); + resolution.setResolutionOfficer(officer); + return resolution; + } } diff --git a/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatorTest.java b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatorTest.java new file mode 100644 index 000000000..87b850906 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/applicationentryresult/validator/BulkApplicationEntryResultDeletionValidatorTest.java @@ -0,0 +1,170 @@ +package uk.gov.hmcts.appregister.applicationentryresult.validator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import uk.gov.hmcts.appregister.applicationentryresult.exception.ApplicationListEntryResultError; +import uk.gov.hmcts.appregister.applicationlist.exception.ApplicationListError; +import uk.gov.hmcts.appregister.common.entity.AppListEntryResolution; +import uk.gov.hmcts.appregister.common.entity.ApplicationList; +import uk.gov.hmcts.appregister.common.entity.ApplicationListEntry; +import uk.gov.hmcts.appregister.common.entity.ResolutionCode; +import uk.gov.hmcts.appregister.common.entity.repository.AppListEntryResolutionRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListEntryRepository; +import uk.gov.hmcts.appregister.common.entity.repository.ApplicationListRepository; +import uk.gov.hmcts.appregister.common.enumeration.YesOrNo; +import uk.gov.hmcts.appregister.common.exception.AppRegistryException; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultItemDto; +import uk.gov.hmcts.appregister.generated.model.BulkDeleteResultsDto; + +@ExtendWith(MockitoExtension.class) +class BulkApplicationEntryResultDeletionValidatorTest { + + @Mock private ApplicationListRepository applicationListRepository; + @Mock private ApplicationListEntryRepository applicationListEntryRepository; + @Mock private AppListEntryResolutionRepository appListEntryResolutionRepository; + + @InjectMocks private BulkApplicationEntryResultDeletionValidator validator; + + @Test + void validate_whenResultsMissing_thenThrows() { + var exception = + assertThrows( + AppRegistryException.class, + () -> validator.validate(new BulkDeleteResultsDto())); + + assertThat(exception.getCode()).isEqualTo(ApplicationListError.ENTRY_NOT_PROVIDED); + } + + @Test + void validate_whenDuplicateItems_thenThrows() { + var item = + new BulkDeleteResultItemDto() + .listId(UUID.randomUUID()) + .entryId(UUID.randomUUID()) + .resultId(UUID.randomUUID()); + + var request = new BulkDeleteResultsDto().results(List.of(item, item)); + + var exception = assertThrows(AppRegistryException.class, () -> validator.validate(request)); + + assertThat(exception.getCode()).isEqualTo(ApplicationListError.ENTRY_IDS_MUST_BE_UNIQUE); + } + + @Test + void validate_whenListMissing_thenThrows() { + var item = + new BulkDeleteResultItemDto() + .listId(UUID.randomUUID()) + .entryId(UUID.randomUUID()) + .resultId(UUID.randomUUID()); + + var exception = + assertThrows( + AppRegistryException.class, + () -> + validator.validate( + new BulkDeleteResultsDto().results(List.of(item)))); + + assertThat(exception.getCode()) + .isEqualTo(ApplicationListEntryResultError.APPLICATION_LIST_DOES_NOT_EXIST); + } + + @Test + void validate_whenValid_thenReturnsValidatedItems() { + final UUID listId = UUID.randomUUID(); + final UUID entryId = UUID.randomUUID(); + final UUID resultId = UUID.randomUUID(); + + var list = new ApplicationList(); + list.setUuid(listId); + list.setDeleted(YesOrNo.NO); + list.setStatus(uk.gov.hmcts.appregister.common.enumeration.Status.OPEN); + + var entry = new ApplicationListEntry(); + entry.setUuid(entryId); + entry.setApplicationList(list); + + var code = new ResolutionCode(); + code.setResultCode("RTC"); + + var result = new AppListEntryResolution(); + result.setUuid(resultId); + result.setApplicationList(entry); + result.setResolutionCode(code); + + when(applicationListRepository.findByUuidIncludingDeleteIn(List.of(listId))) + .thenReturn(List.of(list)); + when(applicationListEntryRepository.findByUuidIncludingDeleteIn(List.of(entryId))) + .thenReturn(List.of(entry)); + when(applicationListEntryRepository.findActiveByUuidsAndApplicationListUuids( + List.of(entryId), List.of(listId))) + .thenReturn(List.of(entry)); + when(appListEntryResolutionRepository.findByUuidIn(List.of(resultId))) + .thenReturn(List.of(result)); + + var item = new BulkDeleteResultItemDto().listId(listId).entryId(entryId).resultId(resultId); + var success = + validator.validate( + new BulkDeleteResultsDto().results(List.of(item)), + (request, validated) -> validated); + + assertThat(success.getResults()).hasSize(1); + assertThat(success.getResults().getFirst().args().listId()).isEqualTo(listId); + } + + @Test + void validate_whenResultNotInEntry_thenThrows() { + final UUID listId = UUID.randomUUID(); + final UUID entryId = UUID.randomUUID(); + final UUID resultId = UUID.randomUUID(); + + var list = new ApplicationList(); + list.setUuid(listId); + list.setDeleted(YesOrNo.NO); + list.setStatus(uk.gov.hmcts.appregister.common.enumeration.Status.OPEN); + + var activeEntry = new ApplicationListEntry(); + activeEntry.setUuid(entryId); + activeEntry.setApplicationList(list); + + final UUID otherEntryId = UUID.randomUUID(); + var otherEntry = new ApplicationListEntry(); + otherEntry.setUuid(otherEntryId); + otherEntry.setApplicationList(list); + + var result = new AppListEntryResolution(); + result.setUuid(resultId); + result.setApplicationList(otherEntry); + + when(applicationListRepository.findByUuidIncludingDeleteIn(List.of(listId))) + .thenReturn(List.of(list)); + when(applicationListEntryRepository.findByUuidIncludingDeleteIn(List.of(entryId))) + .thenReturn(List.of(activeEntry)); + when(applicationListEntryRepository.findActiveByUuidsAndApplicationListUuids( + List.of(entryId), List.of(listId))) + .thenReturn(List.of(activeEntry)); + when(appListEntryResolutionRepository.findByUuidIn(List.of(resultId))) + .thenReturn(List.of(result)); + + var item = new BulkDeleteResultItemDto().listId(listId).entryId(entryId).resultId(resultId); + var exception = + assertThrows( + AppRegistryException.class, + () -> + validator.validate( + new BulkDeleteResultsDto().results(List.of(item)))); + + assertThat(exception.getCode()) + .isEqualTo( + ApplicationListEntryResultError.APPLICATION_ENTRY_RESULT_NOT_WITHIN_ENTRY); + } +} From c93db36ff101742ad19e21cf1bfff05db516abfd Mon Sep 17 00:00:00 2001 From: "colin.bradshaw" Date: Tue, 21 Jul 2026 17:50:41 +0100 Subject: [PATCH 8/8] - Add temporary secret logging. --- .../StartupSecretDiagnosticsLogger.java | 70 +++++++++++++++++++ .../StartupSecretDiagnosticsLoggerTest.java | 63 +++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/main/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLogger.java create mode 100644 src/test/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLoggerTest.java diff --git a/src/main/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLogger.java b/src/main/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLogger.java new file mode 100644 index 000000000..de6d37a4d --- /dev/null +++ b/src/main/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLogger.java @@ -0,0 +1,70 @@ +package uk.gov.hmcts.appregister.config; + +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * Logs a narrow startup diagnostic for secret-backed configuration values that are useful when + * debugging config tree mounts in non-production environments. + */ +@Component +@Slf4j +public class StartupSecretDiagnosticsLogger implements ApplicationRunner { + static final String POSTGRES_DATABASE = "POSTGRES_DATABASE"; + static final String POSTGRES_DATABASE_DEFAULT = "appreg-db"; + static final String CSDS_BASE_URL = "CSDS_BASE_URL"; + static final String CSDS_BASE_URL_DEFAULT = "http://noop"; + + private final Environment environment; + private final Path mountedSecretsPath; + + public StartupSecretDiagnosticsLogger( + Environment environment, + @Value("${appreg.startup-secret-diagnostics.mount-path:/mnt/secrets/appreg}") + String mountedSecretsPath) { + this.environment = environment; + this.mountedSecretsPath = Path.of(mountedSecretsPath); + } + + @Override + public void run(ApplicationArguments args) { + logDiagnostic(inspect(POSTGRES_DATABASE, POSTGRES_DATABASE_DEFAULT)); + logDiagnostic(inspect(CSDS_BASE_URL, CSDS_BASE_URL_DEFAULT)); + } + + StartupSecretDiagnostic inspect(String propertyName, String defaultValue) { + var resolvedValue = environment.getProperty(propertyName, defaultValue); + var mountedSecretPath = mountedSecretsPath.resolve(propertyName); + + return new StartupSecretDiagnostic( + propertyName, + resolvedValue, + defaultValue.equals(resolvedValue), + Files.exists(mountedSecretPath), + Files.isReadable(mountedSecretPath)); + } + + private void logDiagnostic(StartupSecretDiagnostic diagnostic) { + log.info( + "Startup secret diagnostic: property={} resolvedValue={} usingDefault={} mountedFilePresent={}" + + " mountedFileReadable={}", + diagnostic.propertyName(), + diagnostic.resolvedValue(), + diagnostic.usingDefault(), + diagnostic.mountedFilePresent(), + diagnostic.mountedFileReadable()); + } + + record StartupSecretDiagnostic( + String propertyName, + String resolvedValue, + boolean usingDefault, + boolean mountedFilePresent, + boolean mountedFileReadable) {} +} diff --git a/src/test/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLoggerTest.java b/src/test/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLoggerTest.java new file mode 100644 index 000000000..e354e03a9 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/appregister/config/StartupSecretDiagnosticsLoggerTest.java @@ -0,0 +1,63 @@ +package uk.gov.hmcts.appregister.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +import nl.altindag.log.LogCaptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; + +class StartupSecretDiagnosticsLoggerTest { + @TempDir Path tempDir; + + @Test + void givenMountedSecretsAndResolvedValues_whenRun_thenLogsMountedAndResolvedState() + throws Exception { + Files.writeString( + tempDir.resolve(StartupSecretDiagnosticsLogger.POSTGRES_DATABASE), "appreg-db"); + Files.writeString( + tempDir.resolve(StartupSecretDiagnosticsLogger.CSDS_BASE_URL), + "https://csds.dev.apps.hmcts.net/api/rest"); + + var environment = + new MockEnvironment() + .withProperty(StartupSecretDiagnosticsLogger.POSTGRES_DATABASE, "appreg-db") + .withProperty( + StartupSecretDiagnosticsLogger.CSDS_BASE_URL, + "https://csds.dev.apps.hmcts.net/api/rest"); + var logger = new StartupSecretDiagnosticsLogger(environment, tempDir.toString()); + var logCaptor = LogCaptor.forClass(StartupSecretDiagnosticsLogger.class); + logCaptor.clearLogs(); + + logger.run(null); + + assertThat(logCaptor.getInfoLogs()) + .contains( + "Startup secret diagnostic: property=POSTGRES_DATABASE resolvedValue=appreg-db " + + "usingDefault=true mountedFilePresent=true mountedFileReadable=true", + "Startup secret diagnostic: property=CSDS_BASE_URL " + + "resolvedValue=https://csds.dev.apps.hmcts.net/api/rest " + + "usingDefault=false mountedFilePresent=true mountedFileReadable=true"); + } + + @Test + void givenMissingCsdsFileAndFallbackValue_whenRun_thenLogsDefaultAndMissingFile() + throws Exception { + Files.writeString( + tempDir.resolve(StartupSecretDiagnosticsLogger.POSTGRES_DATABASE), "appreg-db"); + + var environment = new MockEnvironment(); + var logger = new StartupSecretDiagnosticsLogger(environment, tempDir.toString()); + var logCaptor = LogCaptor.forClass(StartupSecretDiagnosticsLogger.class); + logCaptor.clearLogs(); + + logger.run(null); + + assertThat(logCaptor.getInfoLogs()) + .contains( + "Startup secret diagnostic: property=CSDS_BASE_URL resolvedValue=http://noop " + + "usingDefault=true mountedFilePresent=false mountedFileReadable=false"); + } +}