From 88482d9b7c4f92bf8e586c1f9dadbc99f49417ba Mon Sep 17 00:00:00 2001 From: arun Date: Thu, 26 Mar 2026 21:45:31 +0000 Subject: [PATCH 1/4] HDPI-3764: Add clearFields functionality to support field clearing in draft saves --- .../PossessionClaimResponse.java | 13 +- .../RespondToPossessionDraftSavePage.java | 1 + .../pcs/ccd/service/DraftCaseDataService.java | 110 +++++- .../pcs/ccd/service/ClearFieldsProofTest.java | 324 ++++++++++++++++++ 4 files changed, 437 insertions(+), 11 deletions(-) create mode 100644 src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java diff --git a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java index 88be156431..4ce7a250db 100644 --- a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java +++ b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java @@ -12,22 +12,12 @@ import java.util.List; -/** - * Defendant's response to a possession claim. - * - *

IMPORTANT: {@code @Builder(toBuilder = true)} is REQUIRED. - */ @Builder(toBuilder = true) @Data @NoArgsConstructor @AllArgsConstructor public class PossessionClaimResponse { - /** - * Claimant organisation names visible to defendants. - * Extracted from allClaimants (filtered to PartyRole.CLAIMANT by PCSCaseView). - * Supports multiple claimants (e.g., joint landlords). - */ @CCD( access = {CitizenAccess.class}, typeOverride = FieldType.Collection, @@ -44,5 +34,8 @@ public class PossessionClaimResponse { @CCD(access = {CitizenAccess.class}) private DefendantResponses defendantResponses; + @CCD(ignore = true) + private List clearFields; + } diff --git a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/page/respondpossessionclaim/page/RespondToPossessionDraftSavePage.java b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/page/respondpossessionclaim/page/RespondToPossessionDraftSavePage.java index 28740e2ddc..237db0b6c2 100644 --- a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/page/respondpossessionclaim/page/RespondToPossessionDraftSavePage.java +++ b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/page/respondpossessionclaim/page/RespondToPossessionDraftSavePage.java @@ -42,6 +42,7 @@ private AboutToStartOrSubmitResponse midEvent(CaseDetails void patchUnsubmittedEventData(long caseReference, T eventData, Event UUID userId = getCurrentUserId(); log.info("Patching draft: caseReference={}, eventId={}, userId={}", caseReference, eventId, userId); + List clearFields = extractClearFields(eventData); String patchEventDataJson = writeCaseDataJson(eventData); - patchUnsubmittedCaseData(caseReference, eventId, patchEventDataJson); + patchUnsubmittedCaseData(caseReference, eventId, patchEventDataJson, clearFields); } public void patchUnsubmittedCaseData(long caseReference, EventId eventId, String patchEventDataJson) { @@ -108,6 +118,25 @@ public void patchUnsubmittedCaseData(long caseReference, EventId eventId, String saved.getId(), saved.getCaseReference(), saved.getEventId(), saved.getIdamUserId()); } + public void patchUnsubmittedCaseData(long caseReference, EventId eventId, + String patchEventDataJson, List clearFields) { + UUID userId = getCurrentUserId(); + DraftCaseDataEntity draftCaseDataEntity = draftCaseDataRepository + .findByCaseReferenceAndEventIdAndIdamUserId(caseReference, eventId, userId) + .map(existingDraft -> { + String mergedJson = mergeCaseDataJson(existingDraft.getCaseData(), patchEventDataJson); + + if (clearFields != null && !clearFields.isEmpty()) { + mergedJson = applyClearFieldsAndSerialize(mergedJson, clearFields); + } + + existingDraft.setCaseData(mergedJson); + return existingDraft; + }).orElseGet(() -> createNewDraft(caseReference, eventId, userId, patchEventDataJson)); + + draftCaseDataRepository.save(draftCaseDataEntity); + } + private String mergeCaseDataJson(String baseCaseDataJson, String patchCaseDataJson) { try { return draftCaseJsonMerger.mergeJson(baseCaseDataJson, patchCaseDataJson); @@ -158,4 +187,83 @@ private DraftCaseDataEntity createNewDraft(long caseReference, EventId eventId, return newDraft; } + private List extractClearFields(T eventData) { + if (eventData instanceof PCSCase) { + PCSCase pcsCase = (PCSCase) eventData; + if (pcsCase.getPossessionClaimResponse() != null) { + List clearFields = pcsCase.getPossessionClaimResponse().getClearFields(); + return clearFields != null ? clearFields : List.of(); + } + } + return List.of(); + } + + private String applyClearFieldsAndSerialize(String mergedJson, List clearFields) { + try { + ObjectNode root = (ObjectNode) objectMapper.readTree(mergedJson); + + JsonNode pcrNode = root.at("/possessionClaimResponse"); + if (pcrNode.isObject()) { + ObjectNode pcr = (ObjectNode) pcrNode; + + for (String fieldPath : clearFields) { + setFieldToNull(pcr, fieldPath); + } + + pcr.remove("clearFields"); + } + + Set clearFieldsSet = new HashSet<>(clearFields); + removeNullFieldsExcept(root, "possessionClaimResponse", clearFieldsSet); + + ObjectMapper nullIncludingMapper = objectMapper.copy() + .setSerializationInclusion(JsonInclude.Include.ALWAYS); + + return nullIncludingMapper.writeValueAsString(root); + } catch (JsonProcessingException e) { + log.error("Failed to apply clearFields", e); + throw new UnsubmittedDataException("Failed to clear fields", e); + } + } + + private void setFieldToNull(ObjectNode root, String fieldPath) { + String[] pathSegments = fieldPath.split("\\."); + ObjectNode current = root; + + for (int i = 0; i < pathSegments.length - 1; i++) { + JsonNode next = current.get(pathSegments[i]); + if (next == null || !next.isObject()) { + return; + } + current = (ObjectNode) next; + } + + String fieldName = pathSegments[pathSegments.length - 1]; + current.set(fieldName, current.nullNode()); + } + + private void removeNullFieldsExcept(ObjectNode node, String currentPath, Set keepNulls) { + Iterator> fields = node.fields(); + List toRemove = new ArrayList<>(); + + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String fieldName = field.getKey(); + JsonNode value = field.getValue(); + String fullPath = currentPath.isEmpty() ? fieldName : currentPath + "." + fieldName; + + if (value.isNull()) { + if (!keepNulls.contains(fullPath)) { + toRemove.add(fieldName); + } + } else if (value.isObject()) { + removeNullFieldsExcept((ObjectNode) value, fullPath, keepNulls); + } + } + + for (String fieldName : toRemove) { + node.remove(fieldName); + } + } + } diff --git a/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java b/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java new file mode 100644 index 0000000000..beb8047909 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java @@ -0,0 +1,324 @@ +package uk.gov.hmcts.reform.pcs.ccd.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PROOF TEST: Demonstrates clearFields behavior with realistic scenario. + * + *

Scenario: User initially selected ALL 5 income options, then on second submission + * unchecked 4 options and kept only "moneyFromElsewhere". + */ +class ClearFieldsProofTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + void proveScenario_AllFiveOptionsToOnlyMoneyFromElsewhere() throws Exception { + // ============================================================ + // FRONTEND SENDS: Only moneyFromElsewhere selected + // ============================================================ + String patchJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "incomeFromJobs": "NO", + "pension": "NO", + "universalCredit": "NO", + "otherBenefits": "NO", + "moneyFromElsewhere": "YES", + "moneyFromElsewhereDetails": "Test123" + } + }, + "clearFields": [ + "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsAmount", + "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsFrequency", + "possessionClaimResponse.defendantResponses.householdCircumstances.pensionAmount", + "possessionClaimResponse.defendantResponses.householdCircumstances.pensionFrequency", + "possessionClaimResponse.defendantResponses.householdCircumstances.universalCreditAmount", + "possessionClaimResponse.defendantResponses.householdCircumstances.universalCreditFrequency", + "possessionClaimResponse.defendantResponses.householdCircumstances.otherBenefitsAmount", + "possessionClaimResponse.defendantResponses.householdCircumstances.otherBenefitsFrequency" + ] + } + } + """; + + // ============================================================ + // STEP 1: Extract clearFields + // ============================================================ + JsonNode patchNode = objectMapper.readTree(patchJson); + JsonNode clearFieldsNode = patchNode.at("/possessionClaimResponse/clearFields"); + List clearFields = objectMapper.convertValue(clearFieldsNode, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + + System.out.println("\n========== STEP 1: Extracted clearFields =========="); + clearFields.forEach(field -> System.out.println(" - " + field)); + + // ============================================================ + // STEP 2: Remove clearFields property from patch JSON + // ============================================================ + ObjectNode patchRoot = (ObjectNode) patchNode; + JsonNode pcr = patchRoot.at("/possessionClaimResponse"); + if (pcr.isObject()) { + ((ObjectNode) pcr).remove("clearFields"); + } + String patchWithoutClearFields = objectMapper.writeValueAsString(patchRoot); + + System.out.println("\n========== STEP 2: Patch JSON (clearFields removed) =========="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(objectMapper.readTree(patchWithoutClearFields))); + + // ============================================================ + // STEP 3: Merge patch onto existing data + // ============================================================ + // BEFORE: Database has ALL 5 income options populated + final String existingDraftData = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "incomeFromJobs": "YES", + "incomeFromJobsAmount": 250000, + "incomeFromJobsFrequency": "MONTHLY", + "pension": "YES", + "pensionAmount": 150000, + "pensionFrequency": "MONTHLY", + "universalCredit": "YES", + "universalCreditAmount": 120000, + "universalCreditFrequency": "MONTHLY", + "otherBenefits": "YES", + "otherBenefitsAmount": 80000, + "otherBenefitsFrequency": "WEEKLY", + "moneyFromElsewhere": "YES", + "moneyFromElsewhereDetails": "Rental income" + } + } + } + } + """; + JsonNode base = objectMapper.readTree(existingDraftData); + JsonNode merged = objectMapper.readerForUpdating(base) + .readValue(patchWithoutClearFields); + + System.out.println("\n========== STEP 3: After Merge (BEFORE clearFields) =========="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(merged)); + + // ASSERT: Merged data has old amounts/frequencies still present + JsonNode hcAfterMerge = merged.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + assertThat(hcAfterMerge.get("incomeFromJobs").asText()).isEqualTo("NO"); + assertThat(hcAfterMerge.get("incomeFromJobsAmount")).isNotNull(); // OLD VALUE STILL HERE! + assertThat(hcAfterMerge.get("incomeFromJobsAmount").asInt()).isEqualTo(250000); + assertThat(hcAfterMerge.get("incomeFromJobsFrequency")).isNotNull(); // OLD VALUE STILL HERE! + assertThat(hcAfterMerge.get("pensionAmount").asInt()).isEqualTo(150000); + assertThat(hcAfterMerge.get("universalCreditAmount").asInt()).isEqualTo(120000); + assertThat(hcAfterMerge.get("otherBenefitsAmount").asInt()).isEqualTo(80000); + + // ============================================================ + // STEP 4: Apply clearFields (SET to NULL, not remove!) + // ============================================================ + ObjectNode mergedRoot = (ObjectNode) merged; + + // Set clearFields to null + for (String fieldPath : clearFields) { + setFieldToNull(mergedRoot, fieldPath); + } + + // CRITICAL: Remove all OTHER null fields before serialization + // Only clearFields should have nulls in the output + java.util.Set clearFieldsSet = new java.util.HashSet<>(clearFields); + removeNullFieldsExcept(mergedRoot, "", clearFieldsSet); + + // NOW safe to serialize with null-including mapper + // Only clearFields have nulls, all other nulls were removed + ObjectMapper nullIncludingMapper = new ObjectMapper(); + nullIncludingMapper.setSerializationInclusion( + com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS); + + String finalJson = nullIncludingMapper.writeValueAsString(mergedRoot); + + System.out.println("\n========== STEP 4: After clearFields Applied (SET TO NULL) =========="); + System.out.println(nullIncludingMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(objectMapper.readTree(finalJson))); + + // ============================================================ + // ASSERTIONS: Verify final state + // ============================================================ + JsonNode hcFinal = mergedRoot.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + + // 1. Flags updated correctly + assertThat(hcFinal.get("incomeFromJobs").asText()).isEqualTo("NO"); + assertThat(hcFinal.get("pension").asText()).isEqualTo("NO"); + assertThat(hcFinal.get("universalCredit").asText()).isEqualTo("NO"); + assertThat(hcFinal.get("otherBenefits").asText()).isEqualTo("NO"); + assertThat(hcFinal.get("moneyFromElsewhere").asText()).isEqualTo("YES"); + + // 2. Cleared fields SET TO NULL (field exists but value is null!) + assertThat(hcFinal.has("incomeFromJobsAmount")).isTrue(); + assertThat(hcFinal.get("incomeFromJobsAmount").isNull()).isTrue(); + assertThat(hcFinal.has("incomeFromJobsFrequency")).isTrue(); + assertThat(hcFinal.get("incomeFromJobsFrequency").isNull()).isTrue(); + + assertThat(hcFinal.has("pensionAmount")).isTrue(); + assertThat(hcFinal.get("pensionAmount").isNull()).isTrue(); + assertThat(hcFinal.has("pensionFrequency")).isTrue(); + assertThat(hcFinal.get("pensionFrequency").isNull()).isTrue(); + + assertThat(hcFinal.has("universalCreditAmount")).isTrue(); + assertThat(hcFinal.get("universalCreditAmount").isNull()).isTrue(); + assertThat(hcFinal.has("universalCreditFrequency")).isTrue(); + assertThat(hcFinal.get("universalCreditFrequency").isNull()).isTrue(); + + assertThat(hcFinal.has("otherBenefitsAmount")).isTrue(); + assertThat(hcFinal.get("otherBenefitsAmount").isNull()).isTrue(); + assertThat(hcFinal.has("otherBenefitsFrequency")).isTrue(); + assertThat(hcFinal.get("otherBenefitsFrequency").isNull()).isTrue(); + + // 3. moneyFromElsewhereDetails updated correctly + assertThat(hcFinal.get("moneyFromElsewhereDetails").asText()).isEqualTo("Test123"); + + System.out.println("\n========== PROOF COMPLETE =========="); + System.out.println("✅ All 4 unchecked options have their amounts/frequencies SET TO NULL"); + System.out.println("✅ moneyFromElsewhere kept with updated details"); + System.out.println("✅ Fields exist in JSON with null values (not removed)"); + } + + @Test + void proveOnlyClearFieldsNullsAreIncluded_NotOtherNulls() throws Exception { + // Scenario: Merged JSON has some unrelated null fields + // Only clearFields nulls should be in the output, not the other nulls + + String mergedJsonWithUnrelatedNulls = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "incomeFromJobs": "NO", + "incomeFromJobsAmount": 250000, + "incomeFromJobsFrequency": "MONTHLY", + "someUnrelatedField": null, + "anotherUnrelatedField": null + } + } + } + } + """; + + List clearFields = List.of( + "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsAmount", + "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsFrequency" + ); + + ObjectNode root = (ObjectNode) objectMapper.readTree(mergedJsonWithUnrelatedNulls); + + System.out.println("\n========== BEFORE: Merged JSON with unrelated nulls =========="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); + + // Set clearFields to null + for (String fieldPath : clearFields) { + setFieldToNull(root, fieldPath); + } + + System.out.println("\n========== AFTER setting clearFields to null =========="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); + + // Remove all OTHER nulls (not in clearFields) + java.util.Set clearFieldsSet = new java.util.HashSet<>(clearFields); + removeNullFieldsExcept(root, "", clearFieldsSet); + + System.out.println("\n========== AFTER removing non-clearFields nulls =========="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); + + // Serialize with null-including mapper + ObjectMapper nullIncludingMapper = new ObjectMapper(); + nullIncludingMapper.setSerializationInclusion( + com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS); + String finalJson = nullIncludingMapper.writeValueAsString(root); + + System.out.println("\n========== FINAL: Serialized with ALWAYS =========="); + System.out.println(nullIncludingMapper.writerWithDefaultPrettyPrinter().writeValueAsString( + objectMapper.readTree(finalJson))); + + // ASSERT: Only clearFields nulls are present + JsonNode hc = root.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + + // clearFields nulls are present + assertThat(hc.has("incomeFromJobsAmount")).isTrue(); + assertThat(hc.get("incomeFromJobsAmount").isNull()).isTrue(); + assertThat(hc.has("incomeFromJobsFrequency")).isTrue(); + assertThat(hc.get("incomeFromJobsFrequency").isNull()).isTrue(); + + // Unrelated nulls are REMOVED + assertThat(hc.has("someUnrelatedField")).isFalse(); + assertThat(hc.has("anotherUnrelatedField")).isFalse(); + + // Other field preserved + assertThat(hc.get("incomeFromJobs").asText()).isEqualTo("NO"); + + System.out.println("\n========== PROOF COMPLETE =========="); + System.out.println("✅ Only clearFields have nulls in final JSON"); + System.out.println("✅ Unrelated null fields were removed"); + System.out.println("✅ No bloat from unrelated nulls"); + } + + private void setFieldToNull(ObjectNode root, String fieldPath) { + String[] pathSegments = fieldPath.split("\\."); + ObjectNode current = root; + + for (int i = 0; i < pathSegments.length - 1; i++) { + JsonNode next = current.get(pathSegments[i]); + if (next == null || !next.isObject()) { + return; + } + current = (ObjectNode) next; + } + + String fieldName = pathSegments[pathSegments.length - 1]; + current.set(fieldName, com.fasterxml.jackson.databind.node.NullNode.getInstance()); + } + + /** + * Recursively remove all null fields from the tree EXCEPT the ones in keepNulls set. + * This ensures that only explicitly cleared fields have nulls in the final JSON. + */ + private void removeNullFieldsExcept(ObjectNode node, String currentPath, java.util.Set keepNulls) { + java.util.Iterator> fields = node.fields(); + java.util.List toRemove = new java.util.ArrayList<>(); + + while (fields.hasNext()) { + java.util.Map.Entry field = fields.next(); + String fieldName = field.getKey(); + JsonNode value = field.getValue(); + String fullPath = currentPath.isEmpty() ? fieldName : currentPath + "." + fieldName; + + if (value.isNull()) { + // Only keep this null if it's in clearFields + if (!keepNulls.contains(fullPath)) { + toRemove.add(fieldName); + } + } else if (value.isObject()) { + // Recursively process nested objects + removeNullFieldsExcept((ObjectNode) value, fullPath, keepNulls); + } + } + + // Remove null fields that aren't in clearFields + for (String fieldName : toRemove) { + node.remove(fieldName); + } + } +} From d26073a082415be83cad2dfb13b7d26a8a397ba7 Mon Sep 17 00:00:00 2001 From: arun Date: Fri, 27 Mar 2026 07:19:25 +0000 Subject: [PATCH 2/4] HDPI-3764: Refactor clearFields logic for improved readability and maintainability --- .../pcs/ccd/service/DraftCaseDataService.java | 89 +++++++++++-------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java index 5e1866a1df..82f1558798 100644 --- a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java +++ b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java @@ -1,6 +1,5 @@ package uk.gov.hmcts.reform.pcs.ccd.service; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,13 +18,11 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.UUID; @Service @@ -200,68 +197,88 @@ private List extractClearFields(T eventData) { private String applyClearFieldsAndSerialize(String mergedJson, List clearFields) { try { - ObjectNode root = (ObjectNode) objectMapper.readTree(mergedJson); + ObjectNode root = parseJsonToTree(mergedJson); - JsonNode pcrNode = root.at("/possessionClaimResponse"); - if (pcrNode.isObject()) { - ObjectNode pcr = (ObjectNode) pcrNode; - - for (String fieldPath : clearFields) { - setFieldToNull(pcr, fieldPath); - } + clearFieldsFromPossessionClaimResponse(root, clearFields); + return serializeJsonTree(root); + } catch (JsonProcessingException e) { + log.error("Failed to apply clearFields", e); + throw new UnsubmittedDataException("Failed to clear fields", e); + } + } - pcr.remove("clearFields"); - } + private ObjectNode parseJsonToTree(String json) throws JsonProcessingException { + return (ObjectNode) objectMapper.readTree(json); + } - Set clearFieldsSet = new HashSet<>(clearFields); - removeNullFieldsExcept(root, "possessionClaimResponse", clearFieldsSet); + private void clearFieldsFromPossessionClaimResponse(ObjectNode root, List clearFields) { + JsonNode pcrNode = root.at("/possessionClaimResponse"); + if (!pcrNode.isObject()) { + return; + } - ObjectMapper nullIncludingMapper = objectMapper.copy() - .setSerializationInclusion(JsonInclude.Include.ALWAYS); + ObjectNode possessionClaimResponse = (ObjectNode) pcrNode; - return nullIncludingMapper.writeValueAsString(root); - } catch (JsonProcessingException e) { - log.error("Failed to apply clearFields", e); - throw new UnsubmittedDataException("Failed to clear fields", e); + for (String fieldPath : clearFields) { + removeField(possessionClaimResponse, fieldPath); } + + possessionClaimResponse.remove("clearFields"); + + removeAllNullFields(possessionClaimResponse); + } + + private String serializeJsonTree(ObjectNode root) throws JsonProcessingException { + return objectMapper.writeValueAsString(root); } - private void setFieldToNull(ObjectNode root, String fieldPath) { + private void removeField(ObjectNode root, String fieldPath) { String[] pathSegments = fieldPath.split("\\."); + ObjectNode parentNode = navigateToParentNode(root, pathSegments); + + if (parentNode == null) { + return; + } + + String fieldName = getFieldName(pathSegments); + parentNode.remove(fieldName); + } + + private ObjectNode navigateToParentNode(ObjectNode root, String[] pathSegments) { ObjectNode current = root; for (int i = 0; i < pathSegments.length - 1; i++) { JsonNode next = current.get(pathSegments[i]); if (next == null || !next.isObject()) { - return; + return null; } current = (ObjectNode) next; } - String fieldName = pathSegments[pathSegments.length - 1]; - current.set(fieldName, current.nullNode()); + return current; } - private void removeNullFieldsExcept(ObjectNode node, String currentPath, Set keepNulls) { + private String getFieldName(String[] pathSegments) { + return pathSegments[pathSegments.length - 1]; + } + + private void removeAllNullFields(ObjectNode node) { + List fieldsToRemove = new ArrayList<>(); Iterator> fields = node.fields(); - List toRemove = new ArrayList<>(); while (fields.hasNext()) { Map.Entry field = fields.next(); String fieldName = field.getKey(); - JsonNode value = field.getValue(); - String fullPath = currentPath.isEmpty() ? fieldName : currentPath + "." + fieldName; + JsonNode fieldValue = field.getValue(); - if (value.isNull()) { - if (!keepNulls.contains(fullPath)) { - toRemove.add(fieldName); - } - } else if (value.isObject()) { - removeNullFieldsExcept((ObjectNode) value, fullPath, keepNulls); + if (fieldValue.isNull()) { + fieldsToRemove.add(fieldName); + } else if (fieldValue.isObject()) { + removeAllNullFields((ObjectNode) fieldValue); } } - for (String fieldName : toRemove) { + for (String fieldName : fieldsToRemove) { node.remove(fieldName); } } From 7d3e4708fc2b9b1d422130c325b93b34db1ac3d0 Mon Sep 17 00:00:00 2001 From: arun Date: Fri, 27 Mar 2026 08:32:38 +0000 Subject: [PATCH 3/4] HDPI-3764: Refactor clearFields to remove duplication and add comprehensive integration tests --- .../pcs/ccd/service/DraftCaseDataService.java | 56 +- .../pcs/ccd/service/ClearFieldsProofTest.java | 324 ----------- .../ccd/service/DraftCaseDataServiceTest.java | 532 +++++++++++++++++- 3 files changed, 540 insertions(+), 372 deletions(-) delete mode 100644 src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java diff --git a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java index 82f1558798..4d9a01d5bd 100644 --- a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java +++ b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataService.java @@ -17,10 +17,7 @@ import uk.gov.hmcts.reform.pcs.security.SecurityContextService; import java.io.IOException; -import java.util.ArrayList; -import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -96,31 +93,13 @@ public void patchUnsubmittedEventData(long caseReference, T eventData, Event patchUnsubmittedCaseData(caseReference, eventId, patchEventDataJson, clearFields); } - public void patchUnsubmittedCaseData(long caseReference, EventId eventId, String patchEventDataJson) { - UUID userId = getCurrentUserId(); - DraftCaseDataEntity draftCaseDataEntity = draftCaseDataRepository - .findByCaseReferenceAndEventIdAndIdamUserId(caseReference, eventId, userId) - .map(existingDraft -> { - log.debug("Updating existing draft for userId={}", userId); - existingDraft.setCaseData(mergeCaseDataJson(existingDraft.getCaseData(), patchEventDataJson)); - return existingDraft; - }).orElseGet(() -> { - log.debug("Creating new draft for caseReference={}, eventId={}, userId={}", - caseReference, eventId, userId); - return createNewDraft(caseReference, eventId, userId, patchEventDataJson); - }); - - DraftCaseDataEntity saved = draftCaseDataRepository.save(draftCaseDataEntity); - log.debug("Draft saved successfully: id={}, caseReference={}, eventId={}, userId={}", - saved.getId(), saved.getCaseReference(), saved.getEventId(), saved.getIdamUserId()); - } - public void patchUnsubmittedCaseData(long caseReference, EventId eventId, String patchEventDataJson, List clearFields) { UUID userId = getCurrentUserId(); DraftCaseDataEntity draftCaseDataEntity = draftCaseDataRepository .findByCaseReferenceAndEventIdAndIdamUserId(caseReference, eventId, userId) .map(existingDraft -> { + log.debug("Updating existing draft for userId={}", userId); String mergedJson = mergeCaseDataJson(existingDraft.getCaseData(), patchEventDataJson); if (clearFields != null && !clearFields.isEmpty()) { @@ -129,9 +108,15 @@ public void patchUnsubmittedCaseData(long caseReference, EventId eventId, existingDraft.setCaseData(mergedJson); return existingDraft; - }).orElseGet(() -> createNewDraft(caseReference, eventId, userId, patchEventDataJson)); + }).orElseGet(() -> { + log.debug("Creating new draft for caseReference={}, eventId={}, userId={}", + caseReference, eventId, userId); + return createNewDraft(caseReference, eventId, userId, patchEventDataJson); + }); - draftCaseDataRepository.save(draftCaseDataEntity); + DraftCaseDataEntity saved = draftCaseDataRepository.save(draftCaseDataEntity); + log.debug("Draft saved successfully: id={}, caseReference={}, eventId={}, userId={}", + saved.getId(), saved.getCaseReference(), saved.getEventId(), saved.getIdamUserId()); } private String mergeCaseDataJson(String baseCaseDataJson, String patchCaseDataJson) { @@ -224,8 +209,6 @@ private void clearFieldsFromPossessionClaimResponse(ObjectNode root, List fieldsToRemove = new ArrayList<>(); - Iterator> fields = node.fields(); - - while (fields.hasNext()) { - Map.Entry field = fields.next(); - String fieldName = field.getKey(); - JsonNode fieldValue = field.getValue(); - - if (fieldValue.isNull()) { - fieldsToRemove.add(fieldName); - } else if (fieldValue.isObject()) { - removeAllNullFields((ObjectNode) fieldValue); - } - } - - for (String fieldName : fieldsToRemove) { - node.remove(fieldName); - } - } - } diff --git a/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java b/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java deleted file mode 100644 index beb8047909..0000000000 --- a/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/ClearFieldsProofTest.java +++ /dev/null @@ -1,324 +0,0 @@ -package uk.gov.hmcts.reform.pcs.ccd.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * PROOF TEST: Demonstrates clearFields behavior with realistic scenario. - * - *

Scenario: User initially selected ALL 5 income options, then on second submission - * unchecked 4 options and kept only "moneyFromElsewhere". - */ -class ClearFieldsProofTest { - - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - } - - @Test - void proveScenario_AllFiveOptionsToOnlyMoneyFromElsewhere() throws Exception { - // ============================================================ - // FRONTEND SENDS: Only moneyFromElsewhere selected - // ============================================================ - String patchJson = """ - { - "possessionClaimResponse": { - "defendantResponses": { - "householdCircumstances": { - "incomeFromJobs": "NO", - "pension": "NO", - "universalCredit": "NO", - "otherBenefits": "NO", - "moneyFromElsewhere": "YES", - "moneyFromElsewhereDetails": "Test123" - } - }, - "clearFields": [ - "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsAmount", - "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsFrequency", - "possessionClaimResponse.defendantResponses.householdCircumstances.pensionAmount", - "possessionClaimResponse.defendantResponses.householdCircumstances.pensionFrequency", - "possessionClaimResponse.defendantResponses.householdCircumstances.universalCreditAmount", - "possessionClaimResponse.defendantResponses.householdCircumstances.universalCreditFrequency", - "possessionClaimResponse.defendantResponses.householdCircumstances.otherBenefitsAmount", - "possessionClaimResponse.defendantResponses.householdCircumstances.otherBenefitsFrequency" - ] - } - } - """; - - // ============================================================ - // STEP 1: Extract clearFields - // ============================================================ - JsonNode patchNode = objectMapper.readTree(patchJson); - JsonNode clearFieldsNode = patchNode.at("/possessionClaimResponse/clearFields"); - List clearFields = objectMapper.convertValue(clearFieldsNode, - objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); - - System.out.println("\n========== STEP 1: Extracted clearFields =========="); - clearFields.forEach(field -> System.out.println(" - " + field)); - - // ============================================================ - // STEP 2: Remove clearFields property from patch JSON - // ============================================================ - ObjectNode patchRoot = (ObjectNode) patchNode; - JsonNode pcr = patchRoot.at("/possessionClaimResponse"); - if (pcr.isObject()) { - ((ObjectNode) pcr).remove("clearFields"); - } - String patchWithoutClearFields = objectMapper.writeValueAsString(patchRoot); - - System.out.println("\n========== STEP 2: Patch JSON (clearFields removed) =========="); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter() - .writeValueAsString(objectMapper.readTree(patchWithoutClearFields))); - - // ============================================================ - // STEP 3: Merge patch onto existing data - // ============================================================ - // BEFORE: Database has ALL 5 income options populated - final String existingDraftData = """ - { - "possessionClaimResponse": { - "defendantResponses": { - "householdCircumstances": { - "incomeFromJobs": "YES", - "incomeFromJobsAmount": 250000, - "incomeFromJobsFrequency": "MONTHLY", - "pension": "YES", - "pensionAmount": 150000, - "pensionFrequency": "MONTHLY", - "universalCredit": "YES", - "universalCreditAmount": 120000, - "universalCreditFrequency": "MONTHLY", - "otherBenefits": "YES", - "otherBenefitsAmount": 80000, - "otherBenefitsFrequency": "WEEKLY", - "moneyFromElsewhere": "YES", - "moneyFromElsewhereDetails": "Rental income" - } - } - } - } - """; - JsonNode base = objectMapper.readTree(existingDraftData); - JsonNode merged = objectMapper.readerForUpdating(base) - .readValue(patchWithoutClearFields); - - System.out.println("\n========== STEP 3: After Merge (BEFORE clearFields) =========="); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter() - .writeValueAsString(merged)); - - // ASSERT: Merged data has old amounts/frequencies still present - JsonNode hcAfterMerge = merged.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); - assertThat(hcAfterMerge.get("incomeFromJobs").asText()).isEqualTo("NO"); - assertThat(hcAfterMerge.get("incomeFromJobsAmount")).isNotNull(); // OLD VALUE STILL HERE! - assertThat(hcAfterMerge.get("incomeFromJobsAmount").asInt()).isEqualTo(250000); - assertThat(hcAfterMerge.get("incomeFromJobsFrequency")).isNotNull(); // OLD VALUE STILL HERE! - assertThat(hcAfterMerge.get("pensionAmount").asInt()).isEqualTo(150000); - assertThat(hcAfterMerge.get("universalCreditAmount").asInt()).isEqualTo(120000); - assertThat(hcAfterMerge.get("otherBenefitsAmount").asInt()).isEqualTo(80000); - - // ============================================================ - // STEP 4: Apply clearFields (SET to NULL, not remove!) - // ============================================================ - ObjectNode mergedRoot = (ObjectNode) merged; - - // Set clearFields to null - for (String fieldPath : clearFields) { - setFieldToNull(mergedRoot, fieldPath); - } - - // CRITICAL: Remove all OTHER null fields before serialization - // Only clearFields should have nulls in the output - java.util.Set clearFieldsSet = new java.util.HashSet<>(clearFields); - removeNullFieldsExcept(mergedRoot, "", clearFieldsSet); - - // NOW safe to serialize with null-including mapper - // Only clearFields have nulls, all other nulls were removed - ObjectMapper nullIncludingMapper = new ObjectMapper(); - nullIncludingMapper.setSerializationInclusion( - com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS); - - String finalJson = nullIncludingMapper.writeValueAsString(mergedRoot); - - System.out.println("\n========== STEP 4: After clearFields Applied (SET TO NULL) =========="); - System.out.println(nullIncludingMapper.writerWithDefaultPrettyPrinter() - .writeValueAsString(objectMapper.readTree(finalJson))); - - // ============================================================ - // ASSERTIONS: Verify final state - // ============================================================ - JsonNode hcFinal = mergedRoot.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); - - // 1. Flags updated correctly - assertThat(hcFinal.get("incomeFromJobs").asText()).isEqualTo("NO"); - assertThat(hcFinal.get("pension").asText()).isEqualTo("NO"); - assertThat(hcFinal.get("universalCredit").asText()).isEqualTo("NO"); - assertThat(hcFinal.get("otherBenefits").asText()).isEqualTo("NO"); - assertThat(hcFinal.get("moneyFromElsewhere").asText()).isEqualTo("YES"); - - // 2. Cleared fields SET TO NULL (field exists but value is null!) - assertThat(hcFinal.has("incomeFromJobsAmount")).isTrue(); - assertThat(hcFinal.get("incomeFromJobsAmount").isNull()).isTrue(); - assertThat(hcFinal.has("incomeFromJobsFrequency")).isTrue(); - assertThat(hcFinal.get("incomeFromJobsFrequency").isNull()).isTrue(); - - assertThat(hcFinal.has("pensionAmount")).isTrue(); - assertThat(hcFinal.get("pensionAmount").isNull()).isTrue(); - assertThat(hcFinal.has("pensionFrequency")).isTrue(); - assertThat(hcFinal.get("pensionFrequency").isNull()).isTrue(); - - assertThat(hcFinal.has("universalCreditAmount")).isTrue(); - assertThat(hcFinal.get("universalCreditAmount").isNull()).isTrue(); - assertThat(hcFinal.has("universalCreditFrequency")).isTrue(); - assertThat(hcFinal.get("universalCreditFrequency").isNull()).isTrue(); - - assertThat(hcFinal.has("otherBenefitsAmount")).isTrue(); - assertThat(hcFinal.get("otherBenefitsAmount").isNull()).isTrue(); - assertThat(hcFinal.has("otherBenefitsFrequency")).isTrue(); - assertThat(hcFinal.get("otherBenefitsFrequency").isNull()).isTrue(); - - // 3. moneyFromElsewhereDetails updated correctly - assertThat(hcFinal.get("moneyFromElsewhereDetails").asText()).isEqualTo("Test123"); - - System.out.println("\n========== PROOF COMPLETE =========="); - System.out.println("✅ All 4 unchecked options have their amounts/frequencies SET TO NULL"); - System.out.println("✅ moneyFromElsewhere kept with updated details"); - System.out.println("✅ Fields exist in JSON with null values (not removed)"); - } - - @Test - void proveOnlyClearFieldsNullsAreIncluded_NotOtherNulls() throws Exception { - // Scenario: Merged JSON has some unrelated null fields - // Only clearFields nulls should be in the output, not the other nulls - - String mergedJsonWithUnrelatedNulls = """ - { - "possessionClaimResponse": { - "defendantResponses": { - "householdCircumstances": { - "incomeFromJobs": "NO", - "incomeFromJobsAmount": 250000, - "incomeFromJobsFrequency": "MONTHLY", - "someUnrelatedField": null, - "anotherUnrelatedField": null - } - } - } - } - """; - - List clearFields = List.of( - "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsAmount", - "possessionClaimResponse.defendantResponses.householdCircumstances.incomeFromJobsFrequency" - ); - - ObjectNode root = (ObjectNode) objectMapper.readTree(mergedJsonWithUnrelatedNulls); - - System.out.println("\n========== BEFORE: Merged JSON with unrelated nulls =========="); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); - - // Set clearFields to null - for (String fieldPath : clearFields) { - setFieldToNull(root, fieldPath); - } - - System.out.println("\n========== AFTER setting clearFields to null =========="); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); - - // Remove all OTHER nulls (not in clearFields) - java.util.Set clearFieldsSet = new java.util.HashSet<>(clearFields); - removeNullFieldsExcept(root, "", clearFieldsSet); - - System.out.println("\n========== AFTER removing non-clearFields nulls =========="); - System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)); - - // Serialize with null-including mapper - ObjectMapper nullIncludingMapper = new ObjectMapper(); - nullIncludingMapper.setSerializationInclusion( - com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS); - String finalJson = nullIncludingMapper.writeValueAsString(root); - - System.out.println("\n========== FINAL: Serialized with ALWAYS =========="); - System.out.println(nullIncludingMapper.writerWithDefaultPrettyPrinter().writeValueAsString( - objectMapper.readTree(finalJson))); - - // ASSERT: Only clearFields nulls are present - JsonNode hc = root.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); - - // clearFields nulls are present - assertThat(hc.has("incomeFromJobsAmount")).isTrue(); - assertThat(hc.get("incomeFromJobsAmount").isNull()).isTrue(); - assertThat(hc.has("incomeFromJobsFrequency")).isTrue(); - assertThat(hc.get("incomeFromJobsFrequency").isNull()).isTrue(); - - // Unrelated nulls are REMOVED - assertThat(hc.has("someUnrelatedField")).isFalse(); - assertThat(hc.has("anotherUnrelatedField")).isFalse(); - - // Other field preserved - assertThat(hc.get("incomeFromJobs").asText()).isEqualTo("NO"); - - System.out.println("\n========== PROOF COMPLETE =========="); - System.out.println("✅ Only clearFields have nulls in final JSON"); - System.out.println("✅ Unrelated null fields were removed"); - System.out.println("✅ No bloat from unrelated nulls"); - } - - private void setFieldToNull(ObjectNode root, String fieldPath) { - String[] pathSegments = fieldPath.split("\\."); - ObjectNode current = root; - - for (int i = 0; i < pathSegments.length - 1; i++) { - JsonNode next = current.get(pathSegments[i]); - if (next == null || !next.isObject()) { - return; - } - current = (ObjectNode) next; - } - - String fieldName = pathSegments[pathSegments.length - 1]; - current.set(fieldName, com.fasterxml.jackson.databind.node.NullNode.getInstance()); - } - - /** - * Recursively remove all null fields from the tree EXCEPT the ones in keepNulls set. - * This ensures that only explicitly cleared fields have nulls in the final JSON. - */ - private void removeNullFieldsExcept(ObjectNode node, String currentPath, java.util.Set keepNulls) { - java.util.Iterator> fields = node.fields(); - java.util.List toRemove = new java.util.ArrayList<>(); - - while (fields.hasNext()) { - java.util.Map.Entry field = fields.next(); - String fieldName = field.getKey(); - JsonNode value = field.getValue(); - String fullPath = currentPath.isEmpty() ? fieldName : currentPath + "." + fieldName; - - if (value.isNull()) { - // Only keep this null if it's in clearFields - if (!keepNulls.contains(fullPath)) { - toRemove.add(fieldName); - } - } else if (value.isObject()) { - // Recursively process nested objects - removeNullFieldsExcept((ObjectNode) value, fullPath, keepNulls); - } - } - - // Remove null fields that aren't in clearFields - for (String fieldName : toRemove) { - node.remove(fieldName); - } - } -} diff --git a/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataServiceTest.java b/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataServiceTest.java index 2640681b90..18443b370a 100644 --- a/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/pcs/ccd/service/DraftCaseDataServiceTest.java @@ -1,8 +1,10 @@ package uk.gov.hmcts.reform.pcs.ccd.service; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -20,6 +22,7 @@ import uk.gov.hmcts.reform.pcs.exception.UnsubmittedDataException; import uk.gov.hmcts.reform.pcs.security.SecurityContextService; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -146,7 +149,7 @@ void shouldPatchUnsubmittedCaseDataWithJson() { .thenAnswer(invocation -> invocation.getArgument(0)); // When - underTest.patchUnsubmittedCaseData(CASE_REFERENCE, eventId, caseDataJson); + underTest.patchUnsubmittedCaseData(CASE_REFERENCE, eventId, caseDataJson, List.of()); // Then verify(draftCaseDataRepository).save(unsubmittedCaseDataEntityCaptor.capture()); @@ -230,4 +233,531 @@ void shouldThrowExceptionForJsonExceptionWhenSaving() throws JsonProcessingExcep .hasCause(jsonProcessingException); } + + /** + * Integration tests for clearFields functionality. + * Tests use real ObjectMapper and DraftCaseJsonMerger to verify complete behavior. + */ + @Nested + class ClearFieldsIntegrationTests { + + private ObjectMapper realObjectMapper; + private DraftCaseJsonMerger realMerger; + private DraftCaseDataService serviceWithRealDependencies; + + @BeforeEach + void setUpIntegration() { + realObjectMapper = new ObjectMapper(); + realMerger = new DraftCaseJsonMerger(realObjectMapper); + serviceWithRealDependencies = new DraftCaseDataService( + draftCaseDataRepository, + realObjectMapper, + realMerger, + securityContextService + ); + + UserInfo userInfo = UserInfo.builder() + .uid(USER_ID.toString()) + .build(); + when(securityContextService.getCurrentUserDetails()).thenReturn(userInfo); + } + + @Test + void shouldRemoveFieldsInClearFieldsListWhilePreservingOtherFields() throws Exception { + // GIVEN: Draft with multiple fields at different nesting levels + String existingDraft = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "dateOfBirth": "1990-05-15", + "tenancyStartDate": "2020-01-01", + "contactByPhone": "YES", + "contactByText": "NO", + "freeLegalAdvice": "YES", + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 100000, + "pensionFrequency": "MONTHLY", + "shareIncomeExpenseDetails": "Yes" + } + } + } + } + """; + + DraftCaseDataEntity existingEntity = new DraftCaseDataEntity(); + existingEntity.setCaseData(existingDraft); + + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(existingEntity)); + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // WHEN: Update draft with clearFields list specifying nested paths to remove + String updateJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "NO" + } + } + } + } + """; + + List clearFields = List.of( + "defendantResponses.householdCircumstances.pensionAmount", + "defendantResponses.householdCircumstances.pensionFrequency" + ); + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, updateJson, clearFields); + + // THEN: Verify specified fields removed, other fields preserved + ArgumentCaptor captor = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor.capture()); + + String savedJson = captor.getValue().getCaseData(); + JsonNode savedData = realObjectMapper.readTree(savedJson); + JsonNode responses = savedData.at("/possessionClaimResponse/defendantResponses"); + JsonNode hc = responses.at("/householdCircumstances"); + + // Fields in clearFields list removed + assertThat(hc.get("pension").asText()).isEqualTo("NO"); + assertThat(hc.has("pensionAmount")).isFalse(); + assertThat(hc.has("pensionFrequency")).isFalse(); + + // Other fields preserved via merge + assertThat(responses.get("dateOfBirth").asText()).isEqualTo("1990-05-15"); + assertThat(responses.get("tenancyStartDate").asText()).isEqualTo("2020-01-01"); + assertThat(responses.get("contactByPhone").asText()).isEqualTo("YES"); + assertThat(responses.get("contactByText").asText()).isEqualTo("NO"); + assertThat(responses.get("freeLegalAdvice").asText()).isEqualTo("YES"); + assertThat(hc.get("shareIncomeExpenseDetails").asText()).isEqualTo("Yes"); + } + + @Test + void shouldOnlyClearSpecifiedFieldPathsInClearFieldsList() throws Exception { + // GIVEN: Draft with multiple nested field groups + String existingDraft = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "freeLegalAdvice": "YES", + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 150000, + "pensionFrequency": "MONTHLY", + "incomeFromJobs": "YES", + "incomeFromJobsAmount": 200000, + "incomeFromJobsFrequency": "WEEKLY" + } + } + } + } + """; + + DraftCaseDataEntity existingEntity = new DraftCaseDataEntity(); + existingEntity.setCaseData(existingDraft); + + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(existingEntity)); + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // WHEN: Clear only specific nested paths, not all related fields + String updateJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "NO" + } + } + } + } + """; + + List clearFields = List.of( + "defendantResponses.householdCircumstances.pensionAmount", + "defendantResponses.householdCircumstances.pensionFrequency" + ); + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, updateJson, clearFields); + + // THEN: Verify only specified paths cleared, sibling field groups unaffected + ArgumentCaptor captor = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor.capture()); + + String savedJson = captor.getValue().getCaseData(); + JsonNode savedData = realObjectMapper.readTree(savedJson); + JsonNode hc = savedData.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + + // Specified fields cleared + assertThat(hc.get("pension").asText()).isEqualTo("NO"); + assertThat(hc.has("pensionAmount")).isFalse(); + assertThat(hc.has("pensionFrequency")).isFalse(); + + // Sibling field group preserved + assertThat(hc.get("incomeFromJobs").asText()).isEqualTo("YES"); + assertThat(hc.get("incomeFromJobsAmount").asInt()).isEqualTo(200000); + assertThat(hc.get("incomeFromJobsFrequency").asText()).isEqualTo("WEEKLY"); + } + + @Test + void shouldClearMultipleNestedFieldPathsInSingleDraftUpdate() throws Exception { + // GIVEN: Draft with deeply nested structure and multiple field paths + String existingDraft = """ + { + "possessionClaimResponse": { + "defendantContactDetails": { + "party": { + "firstName": "Arunkumar", + "lastName": "Kumar", + "phoneNumber": "07700 900 982", + "emailAddress": "test@example.com" + } + }, + "defendantResponses": { + "dateOfBirth": "1990-05-15", + "tenancyStartDate": "2023-01-01", + "contactByPhone": "YES", + "freeLegalAdvice": "YES", + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 150000, + "pensionFrequency": "MONTHLY", + "incomeFromJobs": "YES", + "incomeFromJobsAmount": 250000, + "incomeFromJobsFrequency": "WEEKLY", + "universalCredit": "YES", + "universalCreditAmount": 120000, + "universalCreditFrequency": "MONTHLY", + "otherBenefits": "YES", + "otherBenefitsAmount": 80000, + "otherBenefitsFrequency": "WEEKLY", + "moneyFromElsewhere": "YES", + "moneyFromElsewhereDetails": "Rental income", + "shareIncomeExpenseDetails": "Yes" + } + } + } + } + """; + + DraftCaseDataEntity existingEntity = new DraftCaseDataEntity(); + existingEntity.setCaseData(existingDraft); + + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(existingEntity)); + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // WHEN: Clear 8 field paths in single draft update + String updateJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "NO", + "incomeFromJobs": "NO", + "universalCredit": "NO", + "otherBenefits": "NO", + "moneyFromElsewhere": "YES", + "moneyFromElsewhereDetails": "Updated rental income" + } + } + } + } + """; + + List clearFields = List.of( + "defendantResponses.householdCircumstances.pensionAmount", + "defendantResponses.householdCircumstances.pensionFrequency", + "defendantResponses.householdCircumstances.incomeFromJobsAmount", + "defendantResponses.householdCircumstances.incomeFromJobsFrequency", + "defendantResponses.householdCircumstances.universalCreditAmount", + "defendantResponses.householdCircumstances.universalCreditFrequency", + "defendantResponses.householdCircumstances.otherBenefitsAmount", + "defendantResponses.householdCircumstances.otherBenefitsFrequency" + ); + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, updateJson, clearFields); + + // THEN: Verify all 8 paths cleared, nested structure preserved, sibling nodes unaffected + ArgumentCaptor captor = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor.capture()); + + String savedJson = captor.getValue().getCaseData(); + JsonNode savedData = realObjectMapper.readTree(savedJson); + JsonNode contact = savedData.at("/possessionClaimResponse/defendantContactDetails/party"); + JsonNode responses = savedData.at("/possessionClaimResponse/defendantResponses"); + JsonNode hc = responses.at("/householdCircumstances"); + + // Sibling node at different depth preserved + assertThat(contact.get("firstName").asText()).isEqualTo("Arunkumar"); + assertThat(contact.get("lastName").asText()).isEqualTo("Kumar"); + assertThat(contact.get("phoneNumber").asText()).isEqualTo("07700 900 982"); + + // Parent level fields preserved + assertThat(responses.get("dateOfBirth").asText()).isEqualTo("1990-05-15"); + assertThat(responses.get("tenancyStartDate").asText()).isEqualTo("2023-01-01"); + assertThat(responses.get("freeLegalAdvice").asText()).isEqualTo("YES"); + + // 8 field paths cleared (4 field groups × 2 fields each) + assertThat(hc.get("pension").asText()).isEqualTo("NO"); + assertThat(hc.has("pensionAmount")).isFalse(); + assertThat(hc.has("pensionFrequency")).isFalse(); + + assertThat(hc.get("incomeFromJobs").asText()).isEqualTo("NO"); + assertThat(hc.has("incomeFromJobsAmount")).isFalse(); + assertThat(hc.has("incomeFromJobsFrequency")).isFalse(); + + assertThat(hc.get("universalCredit").asText()).isEqualTo("NO"); + assertThat(hc.has("universalCreditAmount")).isFalse(); + assertThat(hc.has("universalCreditFrequency")).isFalse(); + + assertThat(hc.get("otherBenefits").asText()).isEqualTo("NO"); + assertThat(hc.has("otherBenefitsAmount")).isFalse(); + assertThat(hc.has("otherBenefitsFrequency")).isFalse(); + + // Fields not in clearFields list updated via merge + assertThat(hc.get("moneyFromElsewhere").asText()).isEqualTo("YES"); + assertThat(hc.get("moneyFromElsewhereDetails").asText()).isEqualTo("Updated rental income"); + assertThat(hc.get("shareIncomeExpenseDetails").asText()).isEqualTo("Yes"); + } + + @Test + void shouldAllowReenteringClearedFieldsWithNewValuesInSubsequentUpdates() throws Exception { + // GIVEN: Sequential draft updates simulating field lifecycle + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(new DraftCaseDataEntity())) + .thenReturn(Optional.of(new DraftCaseDataEntity())); + + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> { + DraftCaseDataEntity saved = invocation.getArgument(0); + DraftCaseDataEntity returnEntity = new DraftCaseDataEntity(); + returnEntity.setCaseData(saved.getCaseData()); + return returnEntity; + }); + + // UPDATE 1: Create draft with nested fields + String call1Json = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 100000, + "pensionFrequency": "MONTHLY" + } + } + } + } + """; + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, call1Json, List.of()); + + ArgumentCaptor captor1 = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor1.capture()); + String afterCall1 = captor1.getValue().getCaseData(); + + // Setup for update 2 + DraftCaseDataEntity entityAfterCall1 = new DraftCaseDataEntity(); + entityAfterCall1.setCaseData(afterCall1); + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(entityAfterCall1)); + + // UPDATE 2: Clear nested fields using clearFields + String call2Json = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "NO" + } + } + } + } + """; + + List clearFields = List.of( + "defendantResponses.householdCircumstances.pensionAmount", + "defendantResponses.householdCircumstances.pensionFrequency" + ); + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, call2Json, clearFields); + + ArgumentCaptor captor2 = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository, org.mockito.Mockito.times(2)).save(captor2.capture()); + String afterCall2 = captor2.getAllValues().get(1).getCaseData(); + + // Setup for update 3 + DraftCaseDataEntity entityAfterCall2 = new DraftCaseDataEntity(); + entityAfterCall2.setCaseData(afterCall2); + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(entityAfterCall2)); + + // UPDATE 3: Re-add previously cleared fields with different values + String call3Json = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 200000, + "pensionFrequency": "WEEKLY" + } + } + } + } + """; + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, call3Json, List.of()); + + // THEN: Verify cleared fields can be re-added with new values via merge + ArgumentCaptor captor3 = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository, org.mockito.Mockito.times(3)).save(captor3.capture()); + + String finalJson = captor3.getAllValues().get(2).getCaseData(); + JsonNode finalData = realObjectMapper.readTree(finalJson); + JsonNode hc = finalData.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + + assertThat(hc.get("pension").asText()).isEqualTo("YES"); + assertThat(hc.get("pensionAmount").asInt()).isEqualTo(200000); + assertThat(hc.get("pensionFrequency").asText()).isEqualTo("WEEKLY"); + } + + @Test + void shouldMergePartialUpdatesWithoutAffectingUnsentFields() throws Exception { + // GIVEN: Draft with multiple field groups at same nesting level + String existingDraft = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "dateOfBirth": "1990-05-15", + "tenancyStartDate": "2020-01-01", + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 150000, + "pensionFrequency": "MONTHLY" + } + } + } + } + """; + + DraftCaseDataEntity existingEntity = new DraftCaseDataEntity(); + existingEntity.setCaseData(existingDraft); + + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(existingEntity)); + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // WHEN: Merge partial update containing only subset of fields + String updateJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "dateOfBirth": "1992-08-20", + "tenancyStartDate": "2021-06-15" + } + } + } + """; + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, updateJson, List.of()); + + // THEN: Verify sent fields merged, unsent fields preserved + ArgumentCaptor captor = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor.capture()); + + String savedJson = captor.getValue().getCaseData(); + JsonNode savedData = realObjectMapper.readTree(savedJson); + JsonNode responses = savedData.at("/possessionClaimResponse/defendantResponses"); + JsonNode hc = responses.at("/householdCircumstances"); + + // Fields sent in update merged + assertThat(responses.get("dateOfBirth").asText()).isEqualTo("1992-08-20"); + assertThat(responses.get("tenancyStartDate").asText()).isEqualTo("2021-06-15"); + + // Fields not sent in update preserved + assertThat(hc.get("pension").asText()).isEqualTo("YES"); + assertThat(hc.get("pensionAmount").asInt()).isEqualTo(150000); + assertThat(hc.get("pensionFrequency").asText()).isEqualTo("MONTHLY"); + } + + @Test + void shouldOnlyMergeUpdatesWhenClearFieldsIsEmpty() throws Exception { + // GIVEN: Existing draft with nested field values + String existingDraft = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 100000, + "pensionFrequency": "MONTHLY" + } + } + } + } + """; + + DraftCaseDataEntity existingEntity = new DraftCaseDataEntity(); + existingEntity.setCaseData(existingDraft); + + when(draftCaseDataRepository.findByCaseReferenceAndEventIdAndIdamUserId( + CASE_REFERENCE, EventId.respondPossessionClaim, USER_ID)) + .thenReturn(Optional.of(existingEntity)); + when(draftCaseDataRepository.save(any(DraftCaseDataEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // WHEN: Update draft with empty clearFields list + String updateJson = """ + { + "possessionClaimResponse": { + "defendantResponses": { + "householdCircumstances": { + "pension": "YES", + "pensionAmount": 150000 + } + } + } + } + """; + + serviceWithRealDependencies.patchUnsubmittedCaseData( + CASE_REFERENCE, EventId.respondPossessionClaim, updateJson, List.of()); + + // THEN: Verify update merged without field removal + ArgumentCaptor captor = ArgumentCaptor.forClass(DraftCaseDataEntity.class); + verify(draftCaseDataRepository).save(captor.capture()); + + String savedJson = captor.getValue().getCaseData(); + JsonNode savedData = realObjectMapper.readTree(savedJson); + JsonNode hc = savedData.at("/possessionClaimResponse/defendantResponses/householdCircumstances"); + + assertThat(hc.get("pension").asText()).isEqualTo("YES"); + assertThat(hc.get("pensionAmount").asInt()).isEqualTo(150000); + assertThat(hc.get("pensionFrequency").asText()).isEqualTo("MONTHLY"); + } + } } From 2f56ebc1a1106f2574049219725bd5521279801e Mon Sep 17 00:00:00 2001 From: arun Date: Fri, 27 Mar 2026 08:37:20 +0000 Subject: [PATCH 4/4] HDPI-3764: Restore JavaDoc comments removed from PossessionClaimResponse --- .../PossessionClaimResponse.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java index 4ce7a250db..b89e395f9b 100644 --- a/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java +++ b/src/main/java/uk/gov/hmcts/reform/pcs/ccd/domain/respondpossessionclaim/PossessionClaimResponse.java @@ -12,12 +12,22 @@ import java.util.List; +/** + * Defendant's response to a possession claim. + * + *

IMPORTANT: {@code @Builder(toBuilder = true)} is REQUIRED. + */ @Builder(toBuilder = true) @Data @NoArgsConstructor @AllArgsConstructor public class PossessionClaimResponse { + /** + * Claimant organisation names visible to defendants. + * Extracted from allClaimants (filtered to PartyRole.CLAIMANT by PCSCaseView). + * Supports multiple claimants (e.g., joint landlords). + */ @CCD( access = {CitizenAccess.class}, typeOverride = FieldType.Collection,