diff --git a/pom.xml b/pom.xml index c3d2d7cf32..5dcdafb088 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,7 @@ 6.4.1 17.103.131 17.103.163 - 17.0.149 + 17.104.168 17.104.49 17.0.85 17.103.42 diff --git a/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessor.java b/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessor.java index 4b288b6281..e4e3e894b3 100644 --- a/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessor.java +++ b/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessor.java @@ -13,6 +13,7 @@ import uk.gov.justice.core.courts.DefendantsToRemove; import uk.gov.justice.core.courts.Hearing; import uk.gov.justice.core.courts.HearingDay; +import uk.gov.justice.core.courts.JurisdictionType; import uk.gov.justice.core.courts.ListCourtHearing; import uk.gov.justice.core.courts.ListDefendantRequest; import uk.gov.justice.core.courts.ListHearingRequested; @@ -20,6 +21,7 @@ import uk.gov.justice.core.courts.OffencesToRemove; import uk.gov.justice.core.courts.ProsecutionCase; import uk.gov.justice.core.courts.ProsecutionCasesToRemove; +import uk.gov.justice.core.courts.RotaSlot; import uk.gov.justice.core.courts.UpdateHearingForPartialAllocation; import uk.gov.justice.listing.courts.HearingPartiallyUpdated; import uk.gov.justice.listing.events.HearingRequestedForListing; @@ -32,6 +34,7 @@ import uk.gov.justice.services.messaging.JsonEnvelope; import uk.gov.moj.cpp.progression.helper.HearingNotificationHelper; import uk.gov.moj.cpp.progression.service.ApplicationParameters; +import uk.gov.moj.cpp.progression.service.CourtScheduleQueryAdapter; import uk.gov.moj.cpp.progression.service.ListingService; import uk.gov.moj.cpp.progression.service.ProgressionService; import uk.gov.moj.cpp.progression.service.dto.HearingNotificationInputData; @@ -90,6 +93,9 @@ public class ListHearingRequestedProcessor { @Inject private HearingNotificationHelper hearingNotificationHelper; + @Inject + private CourtScheduleQueryAdapter courtScheduleQueryAdapter; + @ServiceComponent(EVENT_PROCESSOR) @Inject private Sender sender; @@ -98,7 +104,18 @@ public class ListHearingRequestedProcessor { @Handles("progression.event.list-hearing-requested") public void handle(final JsonEnvelope jsonEnvelope) { - final ListHearingRequested listHearingRequested = jsonObjectToObjectConverter.convert(jsonEnvelope.payloadAsJsonObject(), ListHearingRequested.class); + ListHearingRequested listHearingRequested = jsonObjectToObjectConverter.convert(jsonEnvelope.payloadAsJsonObject(), ListHearingRequested.class); + + // Defence-in-depth for unallocated CROWN: when the user picks a DRAFT court-schedule + // slot via the CCP2 UI, the payload arrives with courtCentre.roomId / roomName + // populated (denormalised from bookedSlots for display purposes). That data must + // not be written to hearing.payload or surfaced in the new-hearing notification + // email - for an unallocated hearing there is no confirmed courtroom yet. Listing + // strips this on its side (HearingEnrichmentOrchestrator.stripRoomInfoIfAnyDraft, + // SPRDT-858) but progression sees the payload first and would otherwise leak it + // into hearing.payload and the notification. Mirror the strip here by asking + // listing-query-api whether any of the booked sessions is DRAFT. + listHearingRequested = stripCourtCentreRoomIfAnyDraftSession(listHearingRequested, jsonEnvelope); final ListCourtHearing listCourtHearing = convertListCourtHearing(listHearingRequested, jsonEnvelope); @@ -116,6 +133,48 @@ public void handle(final JsonEnvelope jsonEnvelope) { } } + private ListHearingRequested stripCourtCentreRoomIfAnyDraftSession(final ListHearingRequested original, final JsonEnvelope jsonEnvelope) { + final CourtHearingRequest listNewHearing = original.getListNewHearing(); + if (listNewHearing == null + || !JurisdictionType.CROWN.equals(listNewHearing.getJurisdictionType()) + || listNewHearing.getBookedSlots() == null + || listNewHearing.getBookedSlots().isEmpty() + || listNewHearing.getCourtCentre() == null + || listNewHearing.getCourtCentre().getRoomId() == null) { + return original; + } + + final List courtScheduleIds = listNewHearing.getBookedSlots().stream() + .map(RotaSlot::getCourtScheduleId) + .filter(Objects::nonNull) + .filter(s -> !s.isBlank()) + .map(UUID::fromString) + .collect(toList()); + + if (courtScheduleIds.isEmpty() || !courtScheduleQueryAdapter.anySessionIsDraft(jsonEnvelope, courtScheduleIds)) { + return original; + } + + LOGGER.info("Stripping courtCentre.roomId/roomName for hearingId={} - at least one booked court-schedule session is DRAFT (unallocated CROWN)", + original.getHearingId()); + + final CourtCentre strippedCourtCentre = CourtCentre.courtCentre() + .withValuesFrom(listNewHearing.getCourtCentre()) + .withRoomId(null) + .withRoomName(null) + .build(); + + final CourtHearingRequest strippedListNewHearing = CourtHearingRequest.courtHearingRequest() + .withValuesFrom(listNewHearing) + .withCourtCentre(strippedCourtCentre) + .build(); + + return ListHearingRequested.listHearingRequested() + .withValuesFrom(original) + .withListNewHearing(strippedListNewHearing) + .build(); + } + @Handles("public.listing.hearing-requested-for-listing") public void handlePublicEvent(final JsonEnvelope jsonEnvelope) { final HearingRequestedForListing hearingRequestedForListing = jsonObjectToObjectConverter.convert(jsonEnvelope.payloadAsJsonObject(), HearingRequestedForListing.class); diff --git a/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapter.java b/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapter.java new file mode 100644 index 0000000000..8f2832bd92 --- /dev/null +++ b/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapter.java @@ -0,0 +1,94 @@ +package uk.gov.moj.cpp.progression.service; + +import static javax.json.Json.createArrayBuilder; +import static javax.json.Json.createObjectBuilder; +import static uk.gov.justice.services.core.annotation.Component.EVENT_PROCESSOR; + +import uk.gov.justice.services.core.annotation.ServiceComponent; +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.core.requester.Requester; +import uk.gov.justice.services.messaging.JsonEnvelope; + +import java.util.List; +import java.util.UUID; + +import javax.inject.Inject; +import javax.json.JsonArrayBuilder; +import javax.json.JsonObject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Calls cpp-context-listing's {@code listing.query.court.schedule.draft.status} query to + * decide whether any of a hearing's booked court-schedule sessions is DRAFT. + * + *

Used by {@code ListHearingRequestedProcessor} to detect unallocated CROWN hearings so + * the courtroom info denormalised onto {@code courtCentre} by the UI can be stripped before + * progression writes {@code hearing.payload} or sends the new-hearing notification email. + * Mirrors the intent of listing-side + * {@code HearingEnrichmentOrchestrator.stripRoomInfoIfAnyDraft} (SPRDT-858) so the strip + * happens regardless of which side observes the payload first. + * + *

Goes through listing's query-api (not directly to listingcourtscheduler) so the cross-context + * boundary stays clean - progression only ever talks to listing-context, never bypasses to + * listingcourtscheduler-api. Fails-safe by returning {@code true} ("treat as DRAFT") if the + * remote call returns an unexpected payload; leaking a phantom courtroom into a notification + * email or persisted hearing snapshot is worse than dropping room info for what may be a + * confirmed-allocated hearing. + */ +public class CourtScheduleQueryAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(CourtScheduleQueryAdapter.class); + + private static final String LISTING_QUERY_COURT_SCHEDULE_DRAFT_STATUS = "listing.query.court.schedule.draft.status"; + private static final String COURT_SCHEDULE_ID_LIST = "courtScheduleIdList"; + private static final String ANY_DRAFT = "anyDraft"; + + @Inject + @ServiceComponent(EVENT_PROCESSOR) + private Requester requester; + + @Inject + private Enveloper enveloper; + + public boolean anySessionIsDraft(final JsonEnvelope sourceEnvelope, final List courtScheduleIds) { + if (courtScheduleIds == null || courtScheduleIds.isEmpty()) { + return false; + } + + final JsonArrayBuilder list = createArrayBuilder(); + courtScheduleIds.forEach(id -> list.add(id.toString())); + final JsonObject requestPayload = createObjectBuilder() + .add(COURT_SCHEDULE_ID_LIST, list) + .build(); + + final JsonEnvelope response; + try { + response = requester.requestAsAdmin(enveloper + .withMetadataFrom(sourceEnvelope, LISTING_QUERY_COURT_SCHEDULE_DRAFT_STATUS) + .apply(requestPayload)); + } catch (Exception ex) { + // Fail-open: if we cannot prove a session is draft (e.g. listing not yet deployed + // with the endpoint, dispatch error, network blip) we preserve the original + // payload by returning false. The original unallocated-CROWN leak resumes during + // the outage, but allocated CROWN hearings keep their courtroom info - which is + // the much more common case. Operators will see this WARN if the call is + // persistently failing. + LOGGER.warn("listing.query.court.schedule.draft.status threw {} for {} ids - failing-open (anyDraft=false, no strip)", + ex.getClass().getSimpleName(), courtScheduleIds.size()); + return false; + } + + final JsonObject responseBody = response == null ? null : response.payloadAsJsonObject(); + if (responseBody == null || !responseBody.containsKey(ANY_DRAFT) || responseBody.isNull(ANY_DRAFT)) { + // Same fail-open rationale as the catch block above - if listing's response is + // malformed we cannot prove draft state, so preserve the input. + LOGGER.warn("listing.query.court.schedule.draft.status returned an unexpected payload for {} ids - failing-open (anyDraft=false, no strip)", + courtScheduleIds.size()); + return false; + } + + return responseBody.getBoolean(ANY_DRAFT); + } +} diff --git a/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessorTest.java b/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessorTest.java index 31c4b9c28e..892fa51e3a 100644 --- a/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessorTest.java +++ b/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/processor/ListHearingRequestedProcessorTest.java @@ -36,6 +36,7 @@ import uk.gov.justice.core.courts.PersonDefendant; import uk.gov.justice.core.courts.ProsecutionCase; import uk.gov.justice.core.courts.ProsecutionCaseIdentifier; +import uk.gov.justice.core.courts.RotaSlot; import uk.gov.justice.core.courts.UpdateHearingForPartialAllocation; import uk.gov.justice.core.courts.notification.EmailChannel; import uk.gov.justice.listing.courts.Defendants; @@ -56,6 +57,7 @@ import uk.gov.moj.cpp.progression.helper.HearingNotificationHelper; import uk.gov.moj.cpp.progression.eventprocessorstore.persistence.repository.NotificationInfoJdbcRepository; import uk.gov.moj.cpp.progression.service.ApplicationParameters; +import uk.gov.moj.cpp.progression.service.CourtScheduleQueryAdapter; import uk.gov.moj.cpp.progression.service.DefenceService; import uk.gov.moj.cpp.progression.service.DocumentGeneratorService; import uk.gov.moj.cpp.progression.service.ListingService; @@ -151,6 +153,9 @@ public class ListHearingRequestedProcessorTest { @Mock private ApplicationParameters applicationParameters; + @Mock + private CourtScheduleQueryAdapter courtScheduleQueryAdapter; + private ObjectMapper objectMapper = new ObjectMapperProducer().objectMapper(); @Spy @@ -835,6 +840,175 @@ public void shouldNotUpdateYouthWhenFirstListingAndNoDefendants() { org.mockito.Mockito.verify(sender, org.mockito.Mockito.never()).send(any()); } + // ─── courtCentre.roomId / roomName strip for unallocated CROWN ─────────────────── + + @Test + public void shouldStripCourtCentreRoomWhenCrownHearingHasDraftSession() { + final UUID scheduleId = randomUUID(); + final CourtHearingRequest crownHearingWithRoom = crownBookedSlotPayloadWithCourtCentreRoom(scheduleId); + final ListHearingRequested event = ListHearingRequested.listHearingRequested() + .withHearingId(randomUUID()) + .withListNewHearing(crownHearingWithRoom) + .withSendNotificationToParties(false) + .build(); + final JsonEnvelope envelope = envelopeFrom( + MetadataBuilderFactory.metadataWithRandomUUID("progression.event.list-hearing-requested"), + objectToJsonObjectConverter.convert(event)); + + when(courtScheduleQueryAdapter.anySessionIsDraft(any(JsonEnvelope.class), any())).thenReturn(true); + final ArgumentCaptor transformerArg = ArgumentCaptor.forClass(CourtHearingRequest.class); + when(listCourtHearingTransformer.transform(any(JsonEnvelope.class), any(List.class), transformerArg.capture(), any(UUID.class))) + .thenReturn(ListCourtHearing.listCourtHearing().build()); + + listHearingRequestedProcessor.handle(envelope); + + // After the strip, the CourtHearingRequest handed to the downstream transformer must + // have null roomId / roomName on courtCentre, even though the input had them populated. + assertThat(transformerArg.getValue().getCourtCentre().getRoomId(), is((UUID) null)); + assertThat(transformerArg.getValue().getCourtCentre().getRoomName(), is((String) null)); + } + + @Test + public void shouldNotStripCourtCentreRoomWhenCrownHearingHasOnlyNonDraftSessions() { + final UUID scheduleId = randomUUID(); + final CourtHearingRequest crownHearingWithRoom = crownBookedSlotPayloadWithCourtCentreRoom(scheduleId); + // Capture the exact roomId / roomName on the way in so we can assert byte-for-byte + // preservation through the handler. (Previously this asserted only that + // toString().isEmpty() == false, which is trivially true for any UUID and would have + // passed even if the strip leaked through.) + final UUID expectedRoomId = crownHearingWithRoom.getCourtCentre().getRoomId(); + final String expectedRoomName = crownHearingWithRoom.getCourtCentre().getRoomName(); + final ListHearingRequested event = ListHearingRequested.listHearingRequested() + .withHearingId(randomUUID()) + .withListNewHearing(crownHearingWithRoom) + .withSendNotificationToParties(false) + .build(); + final JsonEnvelope envelope = envelopeFrom( + MetadataBuilderFactory.metadataWithRandomUUID("progression.event.list-hearing-requested"), + objectToJsonObjectConverter.convert(event)); + + when(courtScheduleQueryAdapter.anySessionIsDraft(any(JsonEnvelope.class), any())).thenReturn(false); + final ArgumentCaptor transformerArg = ArgumentCaptor.forClass(CourtHearingRequest.class); + when(listCourtHearingTransformer.transform(any(JsonEnvelope.class), any(List.class), transformerArg.capture(), any(UUID.class))) + .thenReturn(ListCourtHearing.listCourtHearing().build()); + + listHearingRequestedProcessor.handle(envelope); + + // Hearing is allocated (no draft sessions): courtCentre.roomId / roomName must be + // preserved exactly. This is the regression we hit in ns-ste-ccm-72 where allocated + // CROWN hearings were losing their courtroom in the notification email because the + // adapter fail-safed to "treat as draft" on listing-query errors. Adapter now fails + // open (returns false on error), so non-draft paths flow through unchanged. + assertThat(transformerArg.getValue().getCourtCentre().getRoomId(), is(expectedRoomId)); + assertThat(transformerArg.getValue().getCourtCentre().getRoomName(), is(expectedRoomName)); + } + + @Test + public void shouldSkipAdapterAndNotStripWhenJurisdictionIsMagistrates() { + final UUID roomId = randomUUID(); + final CourtHearingRequest magsHearingWithRoom = CourtHearingRequest.courtHearingRequest() + .withHearingType(HearingType.hearingType().withId(randomUUID()).withDescription("First").build()) + .withCourtCentre(CourtCentre.courtCentre() + .withId(randomUUID()) + .withName("Court 1") + .withRoomId(roomId) + .withRoomName("Courtroom 1") + .build()) + .withJurisdictionType(JurisdictionType.MAGISTRATES) + .withListDefendantRequests(singletonList(ListDefendantRequest.listDefendantRequest() + .withProsecutionCaseId(CASE_ID) + .withDefendantOffences(singletonList(randomUUID())) + .withDefendantId(MULTI_OFFENCE_DEFENDANT_ID) + .build())) + .withEarliestStartDateTime(ZonedDateTime.now()) + .build(); + final ListHearingRequested event = ListHearingRequested.listHearingRequested() + .withHearingId(randomUUID()) + .withListNewHearing(magsHearingWithRoom) + .withSendNotificationToParties(false) + .build(); + final JsonEnvelope envelope = envelopeFrom( + MetadataBuilderFactory.metadataWithRandomUUID("progression.event.list-hearing-requested"), + objectToJsonObjectConverter.convert(event)); + + final ArgumentCaptor transformerArg = ArgumentCaptor.forClass(CourtHearingRequest.class); + when(listCourtHearingTransformer.transform(any(JsonEnvelope.class), any(List.class), transformerArg.capture(), any(UUID.class))) + .thenReturn(ListCourtHearing.listCourtHearing().build()); + + listHearingRequestedProcessor.handle(envelope); + + org.mockito.Mockito.verify(courtScheduleQueryAdapter, org.mockito.Mockito.never()).anySessionIsDraft(any(JsonEnvelope.class), any()); + assertThat(transformerArg.getValue().getCourtCentre().getRoomId(), is(roomId)); + assertThat(transformerArg.getValue().getCourtCentre().getRoomName(), is("Courtroom 1")); + } + + @Test + public void shouldSkipAdapterAndNotStripWhenCrownHearingHasNoBookedSlots() { + final UUID roomId = randomUUID(); + final CourtHearingRequest crownHearingNoBookedSlots = CourtHearingRequest.courtHearingRequest() + .withHearingType(HearingType.hearingType().withId(randomUUID()).withDescription("Trial").build()) + .withCourtCentre(CourtCentre.courtCentre() + .withId(randomUUID()) + .withName("Crown Court 1") + .withRoomId(roomId) + .withRoomName("Courtroom 5") + .build()) + .withJurisdictionType(JurisdictionType.CROWN) + .withListDefendantRequests(singletonList(ListDefendantRequest.listDefendantRequest() + .withProsecutionCaseId(CASE_ID) + .withDefendantOffences(singletonList(randomUUID())) + .withDefendantId(MULTI_OFFENCE_DEFENDANT_ID) + .build())) + .withEarliestStartDateTime(ZonedDateTime.now()) + .build(); + final ListHearingRequested event = ListHearingRequested.listHearingRequested() + .withHearingId(randomUUID()) + .withListNewHearing(crownHearingNoBookedSlots) + .withSendNotificationToParties(false) + .build(); + final JsonEnvelope envelope = envelopeFrom( + MetadataBuilderFactory.metadataWithRandomUUID("progression.event.list-hearing-requested"), + objectToJsonObjectConverter.convert(event)); + + final ArgumentCaptor transformerArg = ArgumentCaptor.forClass(CourtHearingRequest.class); + when(listCourtHearingTransformer.transform(any(JsonEnvelope.class), any(List.class), transformerArg.capture(), any(UUID.class))) + .thenReturn(ListCourtHearing.listCourtHearing().build()); + + listHearingRequestedProcessor.handle(envelope); + + // No bookedSlots means no court-schedule sessions to check; adapter must not be called. + org.mockito.Mockito.verify(courtScheduleQueryAdapter, org.mockito.Mockito.never()).anySessionIsDraft(any(JsonEnvelope.class), any()); + assertThat(transformerArg.getValue().getCourtCentre().getRoomId(), is(roomId)); + assertThat(transformerArg.getValue().getCourtCentre().getRoomName(), is("Courtroom 5")); + } + + private CourtHearingRequest crownBookedSlotPayloadWithCourtCentreRoom(final UUID courtScheduleId) { + return CourtHearingRequest.courtHearingRequest() + .withHearingType(HearingType.hearingType().withId(randomUUID()).withDescription("Trial").build()) + .withCourtCentre(CourtCentre.courtCentre() + .withId(randomUUID()) + .withName("Blackfriars Crown Court") + .withRoomId(randomUUID()) + .withRoomName("Courtroom 01") + .build()) + .withJurisdictionType(JurisdictionType.CROWN) + .withBookedSlots(singletonList(RotaSlot.rotaSlot() + .withCourtScheduleId(courtScheduleId.toString()) + .withCourtRoomId(235) + .withRoomId(randomUUID().toString()) + .withSession("AD") + .withStartTime(ZonedDateTime.now()) + .withDuration(120) + .build())) + .withListDefendantRequests(singletonList(ListDefendantRequest.listDefendantRequest() + .withProsecutionCaseId(CASE_ID) + .withDefendantOffences(singletonList(randomUUID())) + .withDefendantId(MULTI_OFFENCE_DEFENDANT_ID) + .build())) + .withEarliestStartDateTime(ZonedDateTime.now()) + .build(); + } + private CourtHearingRequest receivePayloadOfListHearingRequestWithOneCaseMultipleDefendantsWithReferralReason() { return CourtHearingRequest.courtHearingRequest() .withHearingType(HearingType.hearingType() diff --git a/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapterTest.java b/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapterTest.java new file mode 100644 index 0000000000..fd4934375c --- /dev/null +++ b/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapterTest.java @@ -0,0 +1,151 @@ +package uk.gov.moj.cpp.progression.service; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory.metadataWithRandomUUID; +import static uk.gov.justice.services.messaging.JsonEnvelope.envelopeFrom; + +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.core.requester.Requester; +import uk.gov.justice.services.messaging.JsonEnvelope; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import javax.json.Json; +import javax.json.JsonObject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class CourtScheduleQueryAdapterTest { + + private static final UUID SCHEDULE_ID_1 = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID SCHEDULE_ID_2 = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + @Mock + private Requester requester; + + @Mock + private Enveloper enveloper; + + @Mock + private Function envelopeBuilder; + + @InjectMocks + private CourtScheduleQueryAdapter adapter; + + @Test + public void returnsFalseAndDoesNotCallListingQueryWhenInputIsNull() { + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), null), is(false)); + verify(requester, never()).requestAsAdmin(any()); + } + + @Test + public void returnsFalseAndDoesNotCallListingQueryWhenInputIsEmpty() { + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), Collections.emptyList()), is(false)); + verify(requester, never()).requestAsAdmin(any()); + } + + @Test + public void returnsTrueWhenListingResponseSaysAnyDraft() { + givenListingReturns(true); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1, SCHEDULE_ID_2)), is(true)); + } + + @Test + public void sendsRequestAsFlatArrayOfUuidStringsNotObjectWrapped() { + // Wire contract for the request body to listing.query.court.schedule.draft.status: + // { "courtScheduleIdList": ["", ""] } + // NOT the older form {"courtScheduleIdList": [{"courtScheduleId":""}, ...]}. + // This test captures the actual body built by the adapter so a regression to the + // wrapped form would fail loudly here. + givenListingReturns(false); + final ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(JsonObject.class); + + adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1, SCHEDULE_ID_2)); + + verify(envelopeBuilder).apply(payloadCaptor.capture()); + final JsonObject sent = payloadCaptor.getValue(); + final List idsInPayload = new ArrayList<>(); + sent.getJsonArray("courtScheduleIdList").forEach(v -> idsInPayload.add(((javax.json.JsonString) v).getString())); + assertThat(idsInPayload, containsInAnyOrder(SCHEDULE_ID_1.toString(), SCHEDULE_ID_2.toString())); + } + + @Test + public void sendsRequestPreservingScheduleIdOrder() { + givenListingReturns(false); + final ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(JsonObject.class); + + adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1, SCHEDULE_ID_2)); + + verify(envelopeBuilder).apply(payloadCaptor.capture()); + final List idsInPayload = new ArrayList<>(); + payloadCaptor.getValue().getJsonArray("courtScheduleIdList") + .forEach(v -> idsInPayload.add(((javax.json.JsonString) v).getString())); + assertThat(idsInPayload, contains(SCHEDULE_ID_1.toString(), SCHEDULE_ID_2.toString())); + } + + @Test + public void returnsFalseWhenListingResponseSaysNoDraft() { + givenListingReturns(false); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(false)); + } + + @Test + public void failsOpenToFalseWhenListingResponseOmitsAnyDraftKey() { + // Fail-open: if we cannot prove the session is draft, preserve the input (don't strip). + // Allocated CROWN hearings keep their courtCentre.roomId during a listing outage; + // the unallocated-leak guard is best-effort and resumes once listing is reachable. + givenListingReturnsBody(Json.createObjectBuilder().build()); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(false)); + } + + @Test + public void failsOpenToFalseWhenListingCallThrows() { + when(enveloper.withMetadataFrom(any(JsonEnvelope.class), any(String.class))) + .thenThrow(new RuntimeException("simulated dispatch failure")); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(false)); + } + + @Test + public void failsOpenToFalseWhenListingReturnsNullEnvelope() { + // The Requester contract allows null in some dispatch failures; treat the same way + // as malformed - we cannot prove draft state, so preserve the input. + when(enveloper.withMetadataFrom(any(JsonEnvelope.class), any(String.class))).thenReturn(envelopeBuilder); + when(envelopeBuilder.apply(any())).thenReturn(envelopeFrom(metadataWithRandomUUID("listing.query.court.schedule.draft.status"), Json.createObjectBuilder().build())); + when(requester.requestAsAdmin(any())).thenReturn(null); + + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(false)); + } + + private void givenListingReturns(final boolean anyDraft) { + givenListingReturnsBody(Json.createObjectBuilder().add("anyDraft", anyDraft).build()); + } + + private void givenListingReturnsBody(final JsonObject body) { + final JsonEnvelope response = envelopeFrom(metadataWithRandomUUID("listing.query.court.schedule.draft.status.response"), body); + when(enveloper.withMetadataFrom(any(JsonEnvelope.class), any(String.class))).thenReturn(envelopeBuilder); + when(envelopeBuilder.apply(any())).thenReturn(envelopeFrom(metadataWithRandomUUID("listing.query.court.schedule.draft.status"), Json.createObjectBuilder().build())); + when(requester.requestAsAdmin(any())).thenReturn(response); + } + + private static JsonEnvelope sourceEnvelope() { + return envelopeFrom(metadataWithRandomUUID("progression.event.list-hearing-requested"), + Json.createObjectBuilder().build()); + } +} diff --git a/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/CourtScheduleDraftStatusStripIT.java b/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/CourtScheduleDraftStatusStripIT.java new file mode 100644 index 0000000000..ab6e796c43 --- /dev/null +++ b/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/CourtScheduleDraftStatusStripIT.java @@ -0,0 +1,217 @@ +package uk.gov.moj.cpp.progression; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.findAll; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.time.Duration.ofSeconds; +import static java.util.UUID.randomUUID; +import static org.awaitility.Awaitility.waitAtMost; +import static org.junit.jupiter.api.Assertions.fail; +import static uk.gov.moj.cpp.progression.helper.AbstractTestHelper.getWriteUrl; +import static uk.gov.moj.cpp.progression.helper.PreAndPostConditionHelper.addProsecutionCaseToCrownCourt; +import static uk.gov.moj.cpp.progression.helper.PreAndPostConditionHelper.pollCaseAndGetHearingForDefendant; +import static uk.gov.moj.cpp.progression.helper.RestHelper.postCommand; +import static uk.gov.moj.cpp.progression.helper.StubUtil.setupLoggedInUsersPermissionQueryStub; +import static uk.gov.moj.cpp.progression.stub.ListingStub.stubCourtScheduleDraftStatusReturnsDraft; +import static uk.gov.moj.cpp.progression.stub.ListingStub.stubCourtScheduleDraftStatusReturnsNonDraft; +import static uk.gov.moj.cpp.progression.stub.ListingStub.stubListCourtHearing; + +import java.util.concurrent.TimeUnit; + +import javax.ws.rs.core.HttpHeaders; + +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration test for the unallocated-CROWN courtCentre.roomId/roomName strip behaviour + * in {@code ListHearingRequestedProcessor.stripCourtCentreRoomIfAnyDraftSession}. + * + *

Exercises the full chain: HTTP POST to {@code progression.list-new-hearing} → + * {@code progression.command.list-new-hearing} → HearingAggregate.listNewHearing → + * {@code progression.event.list-hearing-requested} → ListHearingRequestedProcessor.handle → + * Requester call to listing-query-api's {@code listing.query.court.schedule.draft.status} + * (WireMock-stubbed) → onward forwarding to listing-command (WireMock-captured). + * + *

We verify the strip by inspecting the body of the outgoing + * {@code listing.command.list-court-hearing} request captured at the WireMock stub. If + * the strip fires the captured body's courtCentre carries only {@code id} and + * {@code name}; if it skips, the body keeps {@code roomId} and {@code roomName}. Polling + * the progression hearing query API for the same assertion would require knowing the + * generated hearingId, which the ListNewHearingHandler picks at random — verifying + * via the captured downstream request avoids that coupling. + */ +public class CourtScheduleDraftStatusStripIT extends AbstractIT { + + private static final String LISTING_COMMAND_PATH = "/listing-service/command/api/rest/listing/cases"; + private static final String LISTING_COMMAND_TYPE = "application/vnd.listing.command.list-court-hearing+json"; + private static final String PROGRESSION_LIST_NEW_HEARING_TYPE = "application/vnd.progression.list-new-hearing+json"; + + @BeforeEach + public void setUp() { + setupLoggedInUsersPermissionQueryStub(); + } + + @Test + public void shouldStripCourtCentreRoomWhenListingDraftStatusReportsDraft() throws Exception { + // Given a CROWN case and a hearing + final String caseId = randomUUID().toString(); + final String defendantId = randomUUID().toString(); + addProsecutionCaseToCrownCourt(caseId, defendantId); + pollCaseAndGetHearingForDefendant(caseId, defendantId); + + // And listing-query-api reports the booked session is DRAFT + stubCourtScheduleDraftStatusReturnsDraft(); + stubListCourtHearing(); + + // When a CROWN list-new-hearing arrives with bookedSlots AND a populated courtCentre.roomId + final String courtScheduleId = randomUUID().toString(); + final String courtCentreId = "89592405-c29b-3706-b1d3-b1dd3a08b227"; + final String roomId = "d0624ee3-9198-3c8b-94d6-42fb197ebe5e"; + postCommand(getWriteUrl("/listnewhearing"), + PROGRESSION_LIST_NEW_HEARING_TYPE, + buildCrownListNewHearingPayload(caseId, defendantId, courtScheduleId, courtCentreId, roomId)); + + // Then the body forwarded to listing-command has courtCentre WITHOUT roomId / roomName + verifyListCourtHearingHasCourtCentreWithoutRoom(caseId); + } + + @Test + public void shouldPreserveCourtCentreRoomWhenListingDraftStatusReportsNonDraft() throws Exception { + // Given a CROWN case and a hearing + final String caseId = randomUUID().toString(); + final String defendantId = randomUUID().toString(); + addProsecutionCaseToCrownCourt(caseId, defendantId); + pollCaseAndGetHearingForDefendant(caseId, defendantId); + + // And listing-query-api reports the booked session is NOT draft + stubCourtScheduleDraftStatusReturnsNonDraft(); + stubListCourtHearing(); + + // When a CROWN list-new-hearing arrives with bookedSlots AND a populated courtCentre.roomId + final String courtScheduleId = randomUUID().toString(); + final String courtCentreId = "89592405-c29b-3706-b1d3-b1dd3a08b227"; + final String roomId = "d0624ee3-9198-3c8b-94d6-42fb197ebe5e"; + postCommand(getWriteUrl("/listnewhearing"), + PROGRESSION_LIST_NEW_HEARING_TYPE, + buildCrownListNewHearingPayload(caseId, defendantId, courtScheduleId, courtCentreId, roomId)); + + // Then the body forwarded to listing-command preserves courtCentre.roomId / roomName + verifyListCourtHearingPreservesCourtCentreRoom(caseId, roomId); + } + + private static String buildCrownListNewHearingPayload(final String caseId, + final String defendantId, + final String courtScheduleId, + final String courtCentreId, + final String roomId) { + return "{" + + "\"listNewHearing\":{" + + "\"hearingType\":{\"id\":\"fb90d7d1-a591-4deb-92a0-4e3d0d469bce\",\"description\":\"Trial - no witnesses\"}," + + "\"estimatedMinutes\":120," + + "\"earliestStartDateTime\":\"2026-06-15T09:00:00.000Z\"," + + "\"jurisdictionType\":\"CROWN\"," + + "\"bookedSlots\":[{" + + " \"startTime\":\"2026-06-15T09:00:00.000Z\"," + + " \"duration\":120," + + " \"courtScheduleId\":\"" + courtScheduleId + "\"," + + " \"session\":\"AD\"," + + " \"oucode\":\"C01BL00\"," + + " \"courtRoomId\":235," + + " \"courtCentreId\":\"" + courtCentreId + "\"," + + " \"roomId\":\"" + roomId + "\"" + + "}]," + + "\"courtCentre\":{" + + " \"id\":\"" + courtCentreId + "\"," + + " \"name\":\"Blackfriars Crown Court\"," + + " \"roomId\":\"" + roomId + "\"," + + " \"roomName\":\"Courtroom 01\"" + + "}," + + "\"listDefendantRequests\":[{" + + " \"prosecutionCaseId\":\"" + caseId + "\"," + + " \"defendantId\":\"" + defendantId + "\"," + + " \"defendantOffences\":[\"" + randomUUID() + "\"]" + + "}]" + + "}," + + "\"sendNotificationToParties\":false" + + "}"; + } + + private static void verifyListCourtHearingHasCourtCentreWithoutRoom(final String caseId) { + try { + waitAtMost(ofSeconds(30)).pollInterval(500, TimeUnit.MILLISECONDS).until(() -> + findAll(postRequestedFor(urlPathEqualTo(LISTING_COMMAND_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(LISTING_COMMAND_TYPE))) + .stream() + .map(LoggedRequest::getBodyAsString) + .anyMatch(body -> isListCourtHearingForCaseWithStrippedCourtCentre(body, caseId))); + } catch (Exception e) { + fail("listing-command body for case " + caseId + " did not arrive with courtCentre.roomId stripped: " + e.getMessage()); + } + } + + private static void verifyListCourtHearingPreservesCourtCentreRoom(final String caseId, final String expectedRoomId) { + try { + waitAtMost(ofSeconds(30)).pollInterval(500, TimeUnit.MILLISECONDS).until(() -> + findAll(postRequestedFor(urlPathEqualTo(LISTING_COMMAND_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(LISTING_COMMAND_TYPE))) + .stream() + .map(LoggedRequest::getBodyAsString) + .anyMatch(body -> isListCourtHearingForCaseWithPreservedRoom(body, caseId, expectedRoomId))); + } catch (Exception e) { + fail("listing-command body for case " + caseId + " did not arrive with courtCentre.roomId preserved as " + expectedRoomId + ": " + e.getMessage()); + } + } + + private static boolean isListCourtHearingForCaseWithStrippedCourtCentre(final String body, final String caseId) { + try { + final JSONObject payload = new JSONObject(body); + if (!payload.has("hearings") || payload.getJSONArray("hearings").length() == 0) { + return false; + } + final JSONObject hearing = payload.getJSONArray("hearings").getJSONObject(0); + if (!referencesCase(hearing, caseId)) { + return false; + } + // Strip succeeded when the courtCentre block omits roomId AND roomName entirely + // (or has them present as JSON null). bookedSlots[].roomId is intentionally NOT + // stripped — only the denormalised courtCentre fields are. + final JSONObject courtCentre = hearing.optJSONObject("courtCentre"); + if (courtCentre == null) { + return true; + } + return courtCentre.isNull("roomId") && courtCentre.isNull("roomName"); + } catch (JSONException e) { + return false; + } + } + + private static boolean isListCourtHearingForCaseWithPreservedRoom(final String body, final String caseId, final String expectedRoomId) { + try { + final JSONObject payload = new JSONObject(body); + if (!payload.has("hearings") || payload.getJSONArray("hearings").length() == 0) { + return false; + } + final JSONObject hearing = payload.getJSONArray("hearings").getJSONObject(0); + if (!referencesCase(hearing, caseId)) { + return false; + } + final JSONObject courtCentre = hearing.optJSONObject("courtCentre"); + return courtCentre != null + && expectedRoomId.equals(courtCentre.optString("roomId")); + } catch (JSONException e) { + return false; + } + } + + private static boolean referencesCase(final JSONObject hearing, final String caseId) throws JSONException { + if (!hearing.has("prosecutionCases") || hearing.getJSONArray("prosecutionCases").length() == 0) { + return false; + } + return caseId.equals(hearing.getJSONArray("prosecutionCases").getJSONObject(0).getString("id")); + } +} diff --git a/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/stub/ListingStub.java b/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/stub/ListingStub.java index 524926d666..50e0c4f66d 100644 --- a/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/stub/ListingStub.java +++ b/progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/stub/ListingStub.java @@ -38,6 +38,8 @@ public class ListingStub { private static final String LISTING_COMMAND = "/listing-service/command/api/rest/listing/cases"; private static final String LISTING_HEARING_COMMAND_V2 = "/listing-service/command/api/rest/listing/hearings/.*"; private static final String LISTING_DELETE_HEARING_COMMAND = "/listing-command-api/command/api/rest/listing/delete-hearing/"; + private static final String LISTING_COURT_SCHEDULE_DRAFT_STATUS = "/listing-service/query/api/rest/listing/courtScheduleDraftStatus"; + private static final String LISTING_COURT_SCHEDULE_DRAFT_STATUS_RESPONSE_TYPE = "application/vnd.listing.query.court.schedule.draft.status.response+json"; private static final String LISTING_COMMAND_TYPE = "application/vnd.listing.command.list-court-hearing+json"; private static final String LISTING_UNSCHEDULED_HEARING_COMMAND_TYPE = "application/vnd.listing.command.list-unscheduled-court-hearing+json"; @@ -446,6 +448,31 @@ public static void stubListingCotrSearch(final String resource, final String hea .withBody(getPayload(resource).replaceAll("HEARING_ID", hearingId)))); } + /** + * Stubs {@code POST /listing-service/query/api/rest/listing/courtScheduleDraftStatus} to + * return {@code { "anyDraft": true }}. Use in tests that exercise the draft-session strip + * path in {@code ListHearingRequestedProcessor.stripCourtCentreRoomIfAnyDraftSession}. + */ + public static void stubCourtScheduleDraftStatusReturnsDraft() { + stubCourtScheduleDraftStatusInternal("{\"anyDraft\":true}"); + } + + /** + * Stubs {@code POST /listing-service/query/api/rest/listing/courtScheduleDraftStatus} to + * return {@code { "anyDraft": false }}. Use in tests that exercise the non-draft + * (preserve-courtCentre) path - allocated CROWN hearings keep their roomId/roomName. + */ + public static void stubCourtScheduleDraftStatusReturnsNonDraft() { + stubCourtScheduleDraftStatusInternal("{\"anyDraft\":false}"); + } + + private static void stubCourtScheduleDraftStatusInternal(final String body) { + stubFor(post(urlPathEqualTo(LISTING_COURT_SCHEDULE_DRAFT_STATUS)) + .willReturn(aResponse().withStatus(SC_OK) + .withHeader(CONTENT_TYPE, LISTING_COURT_SCHEDULE_DRAFT_STATUS_RESPONSE_TYPE) + .withBody(body))); + } + public static void verifyPostListCourtHearingWithProsecutorInfo(final String caseId, final String defendantId, final String courtScheduleId) { try { waitAtMost(ofSeconds(30)).pollInterval(500, MILLISECONDS).until(() -> getListCourtHearingRequestsAsStream()