From 7c2620e1f0bcf86db003b869dcab974d04232a78 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 19 May 2026 03:17:15 +0100 Subject: [PATCH 1/6] Strip courtCentre.roomId/roomName for unallocated CROWN hearings For an unallocated CROWN hearing (DRAFT court-schedule session), the CCP2 UI sends progression.command.list-new-hearing with courtCentre.roomId and courtCentre.roomName populated (denormalised from bookedSlots for display purposes). Progression today copies that courtCentre verbatim into progression.hearing.payload via ProgressionService.transform- HearingListingNeeds, and ListHearingRequestedProcessor.sendHearing- NotificationsToDefenceAndProsecutor reads courtCentre.getRoomId() into the new-hearing notification email. The result: the email and the persisted hearing snapshot both name a phantom courtroom the hearing is not actually allocated to. Listing already strips this on its side in HearingEnrichmentOrchestra- tor.stripRoomInfoIfAnyDraft (SPRDT-858), but progression observes the payload first and would otherwise leak it before listing sees it. This change adds defence-in-depth at the top of ListHearingRequested- Processor.handle: if jurisdiction is CROWN, bookedSlots is non-empty, and courtCentre.roomId is set, we call listing-query-api's new listing.query.court.schedule.draft.status endpoint via Requester to ask whether any of the booked sessions is DRAFT. If yes, we replace listHearingRequested with a sanitised clone whose courtCentre has null roomId and null roomName. All three downstream operations (listing forward, hearing.payload write, notification) then operate on the sanitised view. Cross-context boundary: progression talks to listing-query-api over the JJ messaging bus via Requester, never directly to listing-court- scheduler. CourtScheduleQueryAdapter fails-safe by reporting anyDraft=true if the remote call throws or returns a malformed payload - leaking a phantom courtroom is worse than dropping room info for what may be a confirmed-allocated hearing. NB: requires listing to publish the listing.query.court.schedule. draft.status endpoint at runtime. The corresponding listing PR adds that endpoint to listing-query-api on team/ccsph2n; this progression PR can merge independently because the call is dispatched by string action name at runtime - compile-time RAML knowledge isn't required. At deploy time, the listing service must be running the post-PR listing version. --- .../ListHearingRequestedProcessor.java | 61 ++++++- .../service/CourtScheduleQueryAdapter.java | 84 +++++++++ .../ListHearingRequestedProcessorTest.java | 164 ++++++++++++++++++ .../CourtScheduleQueryAdapterTest.java | 100 +++++++++++ 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapter.java create mode 100644 progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapterTest.java 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..dce46ffb6c --- /dev/null +++ b/progression-event/progression-event-processor/src/main/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapter.java @@ -0,0 +1,84 @@ +package uk.gov.moj.cpp.progression.service; + +import static javax.json.Json.createArrayBuilder; +import static javax.json.Json.createObjectBuilder; + +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 COURT_SCHEDULE_ID = "courtScheduleId"; + private static final String ANY_DRAFT = "anyDraft"; + + @Inject + 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(createObjectBuilder().add(COURT_SCHEDULE_ID, 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) { + LOGGER.warn("listing.query.court.schedule.draft.status threw {} for {} ids - failing-safe by treating sessions as DRAFT", + ex.getClass().getSimpleName(), courtScheduleIds.size()); + return true; + } + + final JsonObject responseBody = response == null ? null : response.payloadAsJsonObject(); + if (responseBody == null || !responseBody.containsKey(ANY_DRAFT) || responseBody.isNull(ANY_DRAFT)) { + LOGGER.warn("listing.query.court.schedule.draft.status returned an unexpected payload for {} ids - failing-safe by treating sessions as DRAFT", + courtScheduleIds.size()); + return true; + } + + 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..9b2ed4d5a9 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,165 @@ 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); + 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 should be preserved. + assertThat(transformerArg.getValue().getCourtCentre().getRoomId().toString().isEmpty(), is(false)); + assertThat(transformerArg.getValue().getCourtCentre().getRoomName(), is("Courtroom 01")); + } + + @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..cbd6264758 --- /dev/null +++ b/progression-event/progression-event-processor/src/test/java/uk/gov/moj/cpp/progression/service/CourtScheduleQueryAdapterTest.java @@ -0,0 +1,100 @@ +package uk.gov.moj.cpp.progression.service; + +import static org.hamcrest.MatcherAssert.assertThat; +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.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.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 returnsFalseWhenListingResponseSaysNoDraft() { + givenListingReturns(false); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(false)); + } + + @Test + public void failsSafeToTrueWhenListingResponseOmitsAnyDraftKey() { + givenListingReturnsBody(Json.createObjectBuilder().build()); + assertThat(adapter.anySessionIsDraft(sourceEnvelope(), List.of(SCHEDULE_ID_1)), is(true)); + } + + @Test + public void failsSafeToTrueWhenListingCallThrows() { + 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(true)); + } + + 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()); + } +} From 51c829508e570857c9a2a986de0ac045d36064c3 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 19 May 2026 10:31:10 +0100 Subject: [PATCH 2/6] updated enforcer versions --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 09ab59792632c0bd64fec08fed4f386a33064c2c Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 26 May 2026 02:05:15 +0100 Subject: [PATCH 3/6] Qualify Requester injection with @ServiceComponent(EVENT_PROCESSOR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Weld CDI container needs a component qualifier on the Requester field to pick the right RequesterProducer-produced instance. Without it the deploy fails with: java.lang.IllegalStateException: No annotation found to define component for class uk.gov.moj.cpp.progression.service.CourtScheduleQueryAdapter at ComponentNameExtractor.componentFromComponentAnnotation at RequesterProducer.produceRequester Matches the same pattern used by ProgressionService.requester and ListingService.sender — qualifier annotation on the field, no class level @ServiceComponent needed (CourtScheduleQueryAdapter is a plain helper, not a JAX-RS resource or @Handles processor). Unit tests bypass Weld entirely (Mockito @InjectMocks uses field injection without container validation), which is why this only surfaced at deploy time in ns-ste-ccm-72. --- .../moj/cpp/progression/service/CourtScheduleQueryAdapter.java | 3 +++ 1 file changed, 3 insertions(+) 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 index dce46ffb6c..57143f3b99 100644 --- 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 @@ -2,7 +2,9 @@ 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; @@ -45,6 +47,7 @@ public class CourtScheduleQueryAdapter { private static final String ANY_DRAFT = "anyDraft"; @Inject + @ServiceComponent(EVENT_PROCESSOR) private Requester requester; @Inject From b05201baec78da24da8963f43bc14169929965db Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 26 May 2026 02:35:54 +0100 Subject: [PATCH 4/6] Flip CourtScheduleQueryAdapter fail-safe direction (anyDraft=false on error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original fail-safe returned anyDraft=true when the listing-query call threw, came back null, or returned a malformed payload. That makes the "treat as draft → strip room info" path the default during any listing-side outage - which strips the courtroom from ALLOCATED CROWN hearings too. Observed in ns-ste-ccm-72: every CROWN-with-bookedSlots hearing lost its courtCentre.roomId in the notification email because the listing PR (#854) endpoint was not yet deployed there, so every Requester call hit the catch-block and triggered the strip. Flip the direction: on error / null / malformed response, return false (fail-open). The reasoning is asymmetric: - Allocated CROWN (the common case): preserve room info always, even when listing-query is unreachable. Notification email and hearing.payload keep the correct courtroom. - Unallocated CROWN (the case we are guarding against): strip room info only when we can prove the session is draft. During a listing-query outage the original unallocated-leak resumes - which is exactly the same behaviour as before the strip was added, so a regression to a known-bad-but-recoverable state rather than a new silent corruption of allocated hearings. The WARN logs still fire so persistent failures are visible. Test changes: - Renamed two adapter tests from failsSafeToTrue* to failsOpenToFalse* and flipped assertions. - Added a third adapter test for the requestAsAdmin-returns-null path (the Requester contract permits null on some dispatch failures). - Tightened the processor no-strip test to assert exact roomId/roomName preservation rather than the toString().isEmpty() check, which was trivially true for any UUID and would have passed even if the strip leaked into the allocated path. --- .../service/CourtScheduleQueryAdapter.java | 16 ++++++++++---- .../ListHearingRequestedProcessorTest.java | 16 +++++++++++--- .../CourtScheduleQueryAdapterTest.java | 22 +++++++++++++++---- 3 files changed, 43 insertions(+), 11 deletions(-) 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 index 57143f3b99..baf4160fe9 100644 --- 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 @@ -70,16 +70,24 @@ public boolean anySessionIsDraft(final JsonEnvelope sourceEnvelope, final List Date: Tue, 26 May 2026 03:08:06 +0100 Subject: [PATCH 5/6] Add CourtScheduleDraftStatusStripIT covering strip and preserve paths Black-box IT for the unallocated-CROWN courtCentre.roomId/roomName strip behaviour. Exercises the full chain from progression.list-new-hearing through to the body forwarded to listing-command: - shouldStripCourtCentreRoomWhenListingDraftStatusReportsDraft Stubs listing-query-api's court-schedule-draft-status to return {"anyDraft":true} and verifies the body sent on to listing-command has courtCentre with roomId/roomName stripped. This is the unallocated CROWN scenario the user hit on ns-ste-ccm-72. - shouldPreserveCourtCentreRoomWhenListingDraftStatusReportsNonDraft Stubs listing-query-api to return {"anyDraft":false} and verifies the body sent on to listing-command keeps courtCentre.roomId intact. This is the allocated CROWN regression we hit when the adapter was failing-closed to anyDraft=true. Verification approach: we inspect the body of the outgoing listing.command.list-court-hearing request captured by WireMock, rather than polling progression's hearing query API. The ListNewHearingHandler picks a random hearingId for new (non-related) hearings, so polling by ID would require coupling to that random value. Inspecting the captured downstream request body is direct. Stub additions in ListingStub: - stubCourtScheduleDraftStatusReturnsDraft / ReturnsNonDraft stub the new /listing-service/query/api/rest/listing/courtScheduleDraftStatus endpoint to return {"anyDraft":true} or {"anyDraft":false} respectively. --- .../CourtScheduleDraftStatusStripIT.java | 217 ++++++++++++++++++ .../moj/cpp/progression/stub/ListingStub.java | 27 +++ 2 files changed, 244 insertions(+) create mode 100644 progression-integration-test/src/test/java/uk/gov/moj/cpp/progression/CourtScheduleDraftStatusStripIT.java 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() From a118004f992c0b9001598741254ac97a50d84923 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 26 May 2026 10:27:11 +0100 Subject: [PATCH 6/6] Send courtScheduleIdList as flat array of UUID strings Request shape sent to listing.query.court.schedule.draft.status: Before: {"courtScheduleIdList": [{"courtScheduleId": ""}, ...]} After: {"courtScheduleIdList": ["", "", ...]} Mirrors the listing-side schema simplification - every element is a UUID so the per-entry object wrapper added no information. The adapter now appends each id as a JSON string directly. Two new unit tests added that capture the JSON sent to the enveloper and assert (a) the array contains UUID strings (not wrapper objects) and (b) the order is preserved. These pin the wire contract so a regression to the wrapped form would fail loudly here rather than at runtime in ns-ste-ccm-72. Removes the now-unused COURT_SCHEDULE_ID constant. --- .../service/CourtScheduleQueryAdapter.java | 3 +- .../CourtScheduleQueryAdapterTest.java | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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 index baf4160fe9..8f2832bd92 100644 --- 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 @@ -43,7 +43,6 @@ public class CourtScheduleQueryAdapter { 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 COURT_SCHEDULE_ID = "courtScheduleId"; private static final String ANY_DRAFT = "anyDraft"; @Inject @@ -59,7 +58,7 @@ public boolean anySessionIsDraft(final JsonEnvelope sourceEnvelope, final List list.add(createObjectBuilder().add(COURT_SCHEDULE_ID, id.toString()))); + courtScheduleIds.forEach(id -> list.add(id.toString())); final JsonObject requestPayload = createObjectBuilder() .add(COURT_SCHEDULE_ID_LIST, list) .build(); 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 index 08abbb9065..fd4934375c 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -13,6 +15,7 @@ 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; @@ -23,6 +26,7 @@ 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; @@ -63,6 +67,39 @@ public void returnsTrueWhenListingResponseSaysAnyDraft() { 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);