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..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 @@ -44,5 +44,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) { + 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); - existingDraft.setCaseData(mergeCaseDataJson(existingDraft.getCaseData(), patchEventDataJson)); + String mergedJson = mergeCaseDataJson(existingDraft.getCaseData(), patchEventDataJson); + + if (clearFields != null && !clearFields.isEmpty()) { + mergedJson = applyClearFieldsAndSerialize(mergedJson, clearFields); + } + + existingDraft.setCaseData(mergedJson); return existingDraft; }).orElseGet(() -> { log.debug("Creating new draft for caseReference={}, eventId={}, userId={}", @@ -158,4 +169,80 @@ 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 = parseJsonToTree(mergedJson); + + clearFieldsFromPossessionClaimResponse(root, clearFields); + return serializeJsonTree(root); + } catch (JsonProcessingException e) { + log.error("Failed to apply clearFields", e); + throw new UnsubmittedDataException("Failed to clear fields", e); + } + } + + private ObjectNode parseJsonToTree(String json) throws JsonProcessingException { + return (ObjectNode) objectMapper.readTree(json); + } + + private void clearFieldsFromPossessionClaimResponse(ObjectNode root, List clearFields) { + JsonNode pcrNode = root.at("/possessionClaimResponse"); + if (!pcrNode.isObject()) { + return; + } + + ObjectNode possessionClaimResponse = (ObjectNode) pcrNode; + + for (String fieldPath : clearFields) { + removeField(possessionClaimResponse, fieldPath); + } + + possessionClaimResponse.remove("clearFields"); + } + + private String serializeJsonTree(ObjectNode root) throws JsonProcessingException { + return objectMapper.writeValueAsString(root); + } + + 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 null; + } + current = (ObjectNode) next; + } + + return current; + } + + private String getFieldName(String[] pathSegments) { + return pathSegments[pathSegments.length - 1]; + } + } 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"); + } + } }