diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java index 7822d9ed1..9f70a0c75 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java @@ -58,6 +58,7 @@ public class CourtSchedulerServiceAdapter { public static final String COURT_SESSION = "courtSession"; public static final String BUSINESS_TYPE = "businessType"; public static final String JURISDICTION = "jurisdiction"; + public static final String STATUS = "status"; public static final String PANEL_ADULT_YOUTH = "ADULT,YOUTH"; private static final String PANEL = "panel"; public static final String HEARING_ID = "hearingId"; @@ -438,6 +439,7 @@ public HearingIdsResponse getCourtSchedulerHearings(final String ouCode, final String endDate, final Optional exactHearingStartDateTime, final Optional businessTypeOptional, final Optional jurisdiction, + final String status, final String panel, final Integer pageSize, final Integer pageNumber ) { final Map queryParams = new HashMap<>(); @@ -452,6 +454,11 @@ public HearingIdsResponse getCourtSchedulerHearings(final String ouCode, businessTypeOptional.ifPresent(businessType -> queryParams.put(BUSINESS_TYPE, businessType)); jurisdiction.ifPresent(j -> queryParams.put(JURISDICTION, j)); exactHearingStartDateTime.ifPresent(s -> queryParams.put(EXACT_HEARING_START_DATETIME, s.toString())); + // Draft (unallocated) court-schedule sessions are excluded at source when status="FINAL" is + // supplied; a null status leaves the courtscheduler result unfiltered (returns ALL). + if (status != null) { + queryParams.put(STATUS, status); + } final Response hearingsResponse = getCourtSchedulerHearingIds(queryParams); diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterTest.java index 9f4eb253a..6055057a1 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static uk.gov.moj.cpp.listing.common.utils.FileUtil.givenPayload; @@ -19,6 +20,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.stream.IntStream; @@ -31,6 +33,7 @@ import org.hamcrest.CoreMatchers; 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.Spy; @@ -216,13 +219,63 @@ void shouldGetHearingIds() { when(response.getEntity()).thenReturn(hearingIdsResponse); when(hearingSlotsService.getCourtSchedulerHearingIds(anyMap())).thenReturn(response); - final HearingIdsResponse finalResp = courtSchedulerServiceAdapter.getCourtSchedulerHearings(courtCentreId, courtSessionOptional, courtRoomId, startDate, endDate, Optional.of(Instant.now()), businessTypeOptional, Optional.of("MAGISTRATES"), "ADULT,YOUTH", pageSize, pageNumber); + final HearingIdsResponse finalResp = courtSchedulerServiceAdapter.getCourtSchedulerHearings(courtCentreId, courtSessionOptional, courtRoomId, startDate, endDate, Optional.of(Instant.now()), businessTypeOptional, Optional.of("MAGISTRATES"), "FINAL", "ADULT,YOUTH", pageSize, pageNumber); assertThat(finalResp.getUuids().size(), is(4)); assertThat(finalResp.getPageCount(), is(1L)); assertThat(finalResp.getResults(), is(4L)); } + @Test + void getCourtSchedulerHearingsShouldAddStatusFinalToQueryParamsWhenStatusIsFinal() { + final String courtCentreId = UUID.randomUUID().toString(); + final Optional courtSessionOptional = Optional.of("AD"); + final String courtRoomId = UUID.randomUUID().toString(); + final String startDate = LocalDate.now().toString(); + final String endDate = LocalDate.now().plusDays(7).toString(); + final Optional businessTypeOptional = Optional.of("BA123"); + final Integer pageSize = 50; + final Integer pageNumber = 1; + + final JsonObject hearingIdsResponse = givenPayload("/mock-data/azure.rotasl.getHearingIds.stub-data.json"); + + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(hearingIdsResponse); + when(hearingSlotsService.getCourtSchedulerHearingIds(anyMap())).thenReturn(response); + + courtSchedulerServiceAdapter.getCourtSchedulerHearings(courtCentreId, courtSessionOptional, courtRoomId, startDate, endDate, Optional.of(Instant.now()), businessTypeOptional, Optional.of("MAGISTRATES"), "FINAL", "ADULT,YOUTH", pageSize, pageNumber); + + final ArgumentCaptor> queryParamsCaptor = ArgumentCaptor.forClass(Map.class); + verify(hearingSlotsService).getCourtSchedulerHearingIds(queryParamsCaptor.capture()); + + assertThat(queryParamsCaptor.getValue().get("status"), is("FINAL")); + } + + @Test + void getCourtSchedulerHearingsShouldOmitStatusFromQueryParamsWhenStatusIsNull() { + final String courtCentreId = UUID.randomUUID().toString(); + final Optional courtSessionOptional = Optional.of("AD"); + final String courtRoomId = UUID.randomUUID().toString(); + final String startDate = LocalDate.now().toString(); + final String endDate = LocalDate.now().plusDays(7).toString(); + final Optional businessTypeOptional = Optional.of("BA123"); + final Integer pageSize = 50; + final Integer pageNumber = 1; + + final JsonObject hearingIdsResponse = givenPayload("/mock-data/azure.rotasl.getHearingIds.stub-data.json"); + + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(hearingIdsResponse); + when(hearingSlotsService.getCourtSchedulerHearingIds(anyMap())).thenReturn(response); + + courtSchedulerServiceAdapter.getCourtSchedulerHearings(courtCentreId, courtSessionOptional, courtRoomId, startDate, endDate, Optional.of(Instant.now()), businessTypeOptional, Optional.of("MAGISTRATES"), null, "ADULT,YOUTH", pageSize, pageNumber); + + final ArgumentCaptor> queryParamsCaptor = ArgumentCaptor.forClass(Map.class); + verify(hearingSlotsService).getCourtSchedulerHearingIds(queryParamsCaptor.capture()); + + assertFalse(queryParamsCaptor.getValue().containsKey("status")); + } + @Test void shouldValidateSessionAvailability() { final JsonObject validateResponse = givenPayload("/mock-data/azure.rotasl.getHearingSlots.stub-data.json"); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/RangeSearchQueryForCourtCalendarIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/RangeSearchQueryForCourtCalendarIT.java index cbe83d4f6..0743db41f 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/RangeSearchQueryForCourtCalendarIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/RangeSearchQueryForCourtCalendarIT.java @@ -8,10 +8,26 @@ import static uk.gov.justice.services.test.utils.core.random.RandomGenerator.STRING; import static uk.gov.moj.cpp.listing.it.SearchAvailableHearingIT.CASE_IN_HEARING; import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciaryWithCourtCenterForMagistrate; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.PLEA_HEARING_TYPE; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.TRIAL_HEARING_TYPE; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubGetHearingIdsWithBody; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessions; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubProvisionalBookingWithCustomParams; import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.getRandomCourtCenterId; - +import static uk.gov.justice.services.test.utils.core.http.BaseUriProvider.getBaseUri; +import static uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder.requestParams; +import static uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher.payload; +import static uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher.status; +import static uk.gov.moj.cpp.listing.it.util.RestPollerHelper.pollWithDefaults; +import static com.jayway.jsonpath.matchers.JsonPathMatchers.isJson; +import static com.jayway.jsonpath.matchers.JsonPathMatchers.withJsonPath; +import static javax.ws.rs.core.Response.Status.OK; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; + +import uk.gov.justice.services.test.utils.core.http.RequestParams; +import uk.gov.justice.services.test.utils.core.http.ResponseData; import uk.gov.justice.core.courts.JurisdictionType; import uk.gov.justice.services.test.utils.persistence.DatabaseCleaner; import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; @@ -127,6 +143,183 @@ public void shouldRangeSearchCourtCalendarForCrown() throws JsonProcessingExcept checkPayload(payload2, 2, testDataList, 5, 2); } + /** + * PATH A (courtscheduler-backed) regression for SPRDT-1164. When the courtscheduler returns the same + * allocated CROWN hearing id for more than one hearing-day, each day must render as its own row with its + * own hearing-day data. Before the fix, the shared Hearing/properties instance was mutated and re-added, + * so the day-of-week the enrichment finished on won: every row collapsed onto the last date and the real + * date {@code dA} disappeared. The assertion below (dA present in the rendered hearing-days) fails on the + * old code and passes on the fixed code. + */ + @Test + public void crownCourtSchedulerPathKeepsEachHearingDayDistinct() { + final String jurisdictionType = JurisdictionType.CROWN.name(); + final UUID courtCentreId = getRandomCourtCenterId(); + final UUID courtRoomId = new ArrayList<>(COURT_ROOMS.keySet()).get(0); + final UUID hearingId = randomUUID(); + final UUID masterDefendantId = randomUUID(); + final String caseUrn = STRING.next(); + + final ZonedDateTime hearingStartTime = ItClock.nowUtc(); + final LocalDate hearingEndDate = ItClock.today().plusDays(1); + final LocalDate dA = hearingStartTime.toLocalDate(); // the hearing's real (seeded) date + final LocalDate dB = dA.plusDays(1); // a second courtscheduler day for the same id + + final CaseAndDefendantData caseAndDefendantData = new CaseAndDefendantData(hearingId, caseUrn, caseUrn, + masterDefendantId, CASE_IN_HEARING, jurisdictionType, jurisdictionType, null, null); + final ListCourtHearingSteps listCourtHearingSteps = new ListCourtHearingSteps( + HearingsData.hearingsDataWithAllocationDataAndJudiciary(caseAndDefendantData, courtCentreId, courtRoomId, hearingEndDate, hearingStartTime)); + listCourtHearingSteps.whenCaseIsSubmittedForListing(); + new UpdateHearingSteps().pollUntilHearingIsPresentWithHearingId(courtCentreId.toString(), ALLOCATED, getLoggedInUser().toString(), hearingId.toString()); + + // Courtscheduler returns the SAME allocated hearing id for two hearing-days. + final String body = "{ \"results\": 2, \"pageCount\": 1, \"hearingIds\": [" + + "{\"hearingId\":\"" + hearingId + "\",\"courtScheduleId\":\"" + randomUUID() + "\",\"hearingDate\":\"" + dA + "\",\"hearingDayCount\":2,\"hearingDayPosition\":1}," + + "{\"hearingId\":\"" + hearingId + "\",\"courtScheduleId\":\"" + randomUUID() + "\",\"hearingDate\":\"" + dB + "\",\"hearingDayCount\":2,\"hearingDayPosition\":2}" + + "] }"; + stubGetHearingIdsWithBody(body); + + final String url = String.format("%s/listing-query-api/query/api/rest/listing/hearings/range-search" + + "?allocated=true&jurisdictionType=CROWN&courtSession=AD&ouCode=C01CY00&courtCentreId=%s" + + "&startDate=%s&endDate=%s&pageSize=40&pageNumber=1", + getBaseUri(), courtCentreId, ItClock.today(), ItClock.today().plusDays(3)); + final RequestParams requestParams = requestParams(url, "application/vnd.listing.search.hearings.court.calendar+json") + .withHeader(CPP_UID_HEADER.getName(), CPP_UID_HEADER.getValue()) + .build(); + + final ResponseData response = pollWithDefaults(requestParams).until(status().is(OK), + payload().isJson(allOf( + withJsonPath("$.hearings.size()", is(2)), + withJsonPath("$.hearings[0].id", is(hearingId.toString())), + withJsonPath("$.hearings[0].allocated", is(true)), + withJsonPath("$.hearings[0].jurisdictionType", is("CROWN")), + // the real date survives per-row de-duplication (collapses to a single wrong date pre-fix) + withJsonPath("$.hearings[*].hearingDays[*].hearingDate", hasItem(dA.toString())) + )) + ); + + // Explicit assertion on the settled response (also satisfies Sonar java:S2699): the seeded date + // dA is still present after per-day de-duplication instead of collapsing onto a single date. + assertThat(response.getPayload(), + isJson(withJsonPath("$.hearings[*].hearingDays[*].hearingDate", hasItem(dA.toString())))); + } + + /** + * PATH A / PATH B routing regression for SPRDT-1164. Two allocated multi-day CROWN hearings — one + * Trial, one Plea — are seeded in the same court centre over the same 3 consecutive working days. + * courtscheduler's get.hearing.ids is stubbed so a courtSession=AD query returns hearing #1's + * day-tuples and courtSession=AM returns hearing #2's — the AM/AD split is enforced purely by the + * stub, since courtscheduler itself has no notion of hearing type. Covers: + *
    + *
  • Any (no courtSession) — PATH B (listing viewstore), both hearings visible
  • + *
  • AM / AD — PATH A (courtscheduler), only the matching hearing's rows come back
  • + *
  • Any + hearingTypeId=Trial — PATH B's hearingTypeId filter narrows to hearing #1 only
  • + *
  • AM + hearingTypeId=Trial — PATH A returns hearing #2's rows, but listing's + * post-enrichment hearingTypeId filter removes them (hearing #2 is Plea, not Trial) — empty
  • + *
+ */ + @Test + public void shouldRouteCourtCalendarPathAByCourtSessionAndFilterByHearingType() { + final String jurisdictionType = JurisdictionType.CROWN.name(); + final UUID courtCentreId = getRandomCourtCenterId(); + final UUID courtRoomId = new ArrayList<>(COURT_ROOMS.keySet()).get(0); + final String ouCode = "C01CY00"; + + final LocalDate day0 = ItClock.nextWorkingDay(); + final LocalDate day1 = ItClock.plusWorkingDays(day0, 1); + final LocalDate day2 = ItClock.plusWorkingDays(day0, 2); + final List hearingDates = List.of(day0, day1, day2); + final ZonedDateTime hearingStartTime = day0.atTime(10, 0).atZone(ItClock.LONDON).withZoneSameInstant(ItClock.UTC); + + // HEARING #1 -- Trial, representing courtscheduler AD sessions + final UUID hearingId1 = randomUUID(); + final CaseAndDefendantData caseAndDefendantData1 = new CaseAndDefendantData(hearingId1, STRING.next(), STRING.next(), + randomUUID(), CASE_IN_HEARING, jurisdictionType, jurisdictionType, null, null); + new ListCourtHearingSteps(HearingsData.hearingsDataWithAllocationDataAndJudiciary( + caseAndDefendantData1, courtCentreId, courtRoomId, day2, hearingStartTime, TRIAL_HEARING_TYPE)) + .whenCaseIsSubmittedForListing(); + new UpdateHearingSteps().pollUntilHearingIsPresentWithHearingId(courtCentreId.toString(), ALLOCATED, getLoggedInUser().toString(), hearingId1.toString()); + + // HEARING #2 -- Plea, representing courtscheduler AM sessions + final UUID hearingId2 = randomUUID(); + final CaseAndDefendantData caseAndDefendantData2 = new CaseAndDefendantData(hearingId2, STRING.next(), STRING.next(), + randomUUID(), CASE_IN_HEARING, jurisdictionType, jurisdictionType, null, null); + new ListCourtHearingSteps(HearingsData.hearingsDataWithAllocationDataAndJudiciary( + caseAndDefendantData2, courtCentreId, courtRoomId, day2, hearingStartTime, PLEA_HEARING_TYPE)) + .whenCaseIsSubmittedForListing(); + new UpdateHearingSteps().pollUntilHearingIsPresentWithHearingId(courtCentreId.toString(), ALLOCATED, getLoggedInUser().toString(), hearingId2.toString()); + + // PATH A stubs: courtSession=AD -> hearing #1's day-tuples, courtSession=AM -> hearing #2's + stubGetHearingIdsWithBody("AD", hearingIdsBodyForDays(hearingId1, hearingDates)); + stubGetHearingIdsWithBody("AM", hearingIdsBodyForDays(hearingId2, hearingDates)); + + final String baseUrl = String.format("%s/listing-query-api/query/api/rest/listing/hearings/range-search" + + "?allocated=true&jurisdictionType=CROWN&courtCentreId=%s&ouCode=%s" + + "&startDate=%s&endDate=%s&pageSize=50&pageNumber=1", + getBaseUri(), courtCentreId, ouCode, day0, day2); + + // 3. Any (no courtSession) -> PATH B, both hearings present + pollWithDefaults(courtCalendarRequest(baseUrl)).until(status().is(OK), + payload().isJson(allOf( + withJsonPath("$.hearings[*].id", hasItem(hearingId1.toString())), + withJsonPath("$.hearings[*].id", hasItem(hearingId2.toString())) + ))); + + // 4. AM -> PATH A, only hearing #2 + pollWithDefaults(courtCalendarRequest(baseUrl + "&courtSession=AM")).until(status().is(OK), + payload().isJson(allOf( + withJsonPath("$.hearings[*].id", Matchers.everyItem(is(hearingId2.toString()))), + withJsonPath("$.hearings[0].id", is(hearingId2.toString())) + ))); + + // 5. AD -> PATH A, only hearing #1 + pollWithDefaults(courtCalendarRequest(baseUrl + "&courtSession=AD")).until(status().is(OK), + payload().isJson(allOf( + withJsonPath("$.hearings[*].id", Matchers.everyItem(is(hearingId1.toString()))), + withJsonPath("$.hearings[0].id", is(hearingId1.toString())) + ))); + + // 6. Any + hearingTypeId=Trial -> PATH B's viewstore type filter -> only hearing #1 + pollWithDefaults(courtCalendarRequest(baseUrl + "&hearingTypeId=" + TRIAL_HEARING_TYPE.getTypeId())).until(status().is(OK), + payload().isJson(allOf( + withJsonPath("$.hearings[*].id", hasItem(hearingId1.toString())), + withJsonPath("$.hearings[*].id", Matchers.not(hasItem(hearingId2.toString()))) + ))); + + // 7. AM + hearingTypeId=Trial -> PATH A returns hearing #2's rows, but the post-enrichment + // hearingTypeId filter removes them (hearing #2 is Plea, not Trial) -> empty + final ResponseData response = pollWithDefaults(courtCalendarRequest(baseUrl + "&courtSession=AM&hearingTypeId=" + TRIAL_HEARING_TYPE.getTypeId())).until(status().is(OK), + payload().isJson(withJsonPath("$.hearings.size()", is(0)))); + // explicit assertion on the final polled payload (Sonar java:S2699 does not recognise the + // pollWithDefaults(...).until(...) matchers as assertions) + assertThat(response.getPayload(), isJson(withJsonPath("$.hearings.size()", is(0)))); + } + + private static RequestParams courtCalendarRequest(final String url) { + return requestParams(url, "application/vnd.listing.search.hearings.court.calendar+json") + .withHeader(CPP_UID_HEADER.getName(), CPP_UID_HEADER.getValue()) + .build(); + } + + /** Builds a get.hearing.ids stub body with one IdResponse tuple per supplied date, all sharing the + * same hearingId (mirrors how courtscheduler represents a single multi-day hearing). */ + private static String hearingIdsBodyForDays(final UUID hearingId, final List hearingDates) { + final StringBuilder body = new StringBuilder("{\"results\":").append(hearingDates.size()) + .append(",\"pageCount\":1,\"hearingIds\":["); + for (int i = 0; i < hearingDates.size(); i++) { + if (i > 0) { + body.append(","); + } + body.append("{\"hearingId\":\"").append(hearingId).append("\",") + .append("\"courtScheduleId\":\"").append(randomUUID()).append("\",") + .append("\"hearingDate\":\"").append(hearingDates.get(i)).append("\",") + .append("\"hearingDayCount\":").append(hearingDates.size()).append(",") + .append("\"hearingDayPosition\":").append(i + 1).append("}"); + } + body.append("]}"); + return body.toString(); + } + private static void checkPayload(final String payload, final int hearingCount, List testDataList, final int pageSize, final int pageNumber) throws JsonProcessingException { final Map jObj = new ObjectMapper().readValue(payload, Map.class); assertThat(jObj, Matchers.is(notNullValue())); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/HearingsData.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/HearingsData.java index a8645c6af..61d9ec872 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/HearingsData.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/HearingsData.java @@ -207,6 +207,17 @@ public static HearingsData hearingsDataWithAllocationDataAndJudiciary(final Case return new HearingsData(HearingsDataFactory.hearingsDataWithAllocationDataAndJudiciaryWithDate(caseAndDefendantData, courtCentreId, courtRoomId, hearingEndDate, hearingStartTime)); } + /** SPRDT-1164: variant that pins the seeded hearing's hearing type (e.g. Trial/Plea), so the + * viewstore type_id can be asserted on directly by court-calendar hearingTypeId-filter tests. */ + public static HearingsData hearingsDataWithAllocationDataAndJudiciary(final CaseAndDefendantData caseAndDefendantData, + final UUID courtCentreId, + final UUID courtRoomId, + final LocalDate hearingEndDate, + final ZonedDateTime hearingStartTime, + final HearingTypeData hearingTypeData) { + return new HearingsData(HearingsDataFactory.hearingsDataWithAllocationDataAndJudiciaryWithDate(caseAndDefendantData, courtCentreId, courtRoomId, hearingEndDate, hearingStartTime, hearingTypeData)); + } + public static HearingsData hearingsDataWithUnAllocationDataAndJudiciary(final CaseAndDefendantData caseAndDefendantData) { return new HearingsData(HearingsDataFactory.hearingsDataWithUnAllocationDataAndJudiciary(caseAndDefendantData)); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/factory/HearingsDataFactory.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/factory/HearingsDataFactory.java index 5442cc27e..d1fde0d7e 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/factory/HearingsDataFactory.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/data/factory/HearingsDataFactory.java @@ -56,7 +56,11 @@ public class HearingsDataFactory { private static final int HEARING_ESTIMATE_MINUTES = 30; public static final String ESTIMATED_DURATION = "1 week"; private static final HearingTypeData PTP_HEARING_TYPE = new HearingTypeData(UUID.fromString("52edf232-3c09-4c74-a6ad-737985c2e662"), "PTP", "welshPTP"); - private static final HearingTypeData TRIAL_HEARING_TYPE = new HearingTypeData(UUID.fromString("bf8155e1-90b9-4080-b133-bfbad895d6e4"), "Trial", "welsh trial"); + public static final HearingTypeData TRIAL_HEARING_TYPE = new HearingTypeData(UUID.fromString("bf8155e1-90b9-4080-b133-bfbad895d6e4"), "Trial", "welsh trial"); + /** SPRDT-1164: second, distinct hearing type used to discriminate the court-calendar PATH A + * courtSession=AM/AD scenarios from the courtSession=AD/Trial hearing via the listing-side + * hearingTypeId viewstore filter (courtscheduler itself has no notion of hearing type). */ + public static final HearingTypeData PLEA_HEARING_TYPE = new HearingTypeData(UUID.fromString("eab66a6c-bbcf-4dc9-8291-36def87bcfd5"), "Plea", "welsh plea"); private static final BailStatus BAIL_CONDITIONAL = new BailStatus.Builder().withId(fromString("34443c87-fa6f-34c0-897f-0cce45773df5")).withCode("P").withDescription("Custody or remanded into custody").build(); public static final ZonedDateTime SLOT_START_TIME = ItClock.nowUtc(); @@ -259,6 +263,18 @@ public static List hearingsDataWithAllocationDataAndJudiciaryWithDa return manyRandomHearingsWithAllocationDataWithDate(1, caseAndDefendantData, courtCentreId, courtRoomId, hearingEndDate, hearingStartTime); } + /** SPRDT-1164: variant that lets the caller pin the hearing type (e.g. Trial/Plea) so the + * seeded hearing's viewstore type_id can be asserted on by the court-calendar PATH B hearingTypeId + * filter and used to discriminate PATH A courtSession stub responses. */ + public static List hearingsDataWithAllocationDataAndJudiciaryWithDate(final CaseAndDefendantData caseAndDefendantData, + final UUID courtCentreId, + final UUID courtRoomId, + final LocalDate hearingEndDate, + final ZonedDateTime hearingStartTime, + final HearingTypeData hearingTypeData) { + return manyRandomHearingsWithAllocationDataWithDate(1, caseAndDefendantData, courtCentreId, courtRoomId, hearingEndDate, hearingStartTime, hearingTypeData); + } + public static List hearingsDataWithUnAllocationDataAndJudiciary(final CaseAndDefendantData caseAndDefendantData) { return manyRandomHearingsWithUnAllocationData(2, caseAndDefendantData); } @@ -423,6 +439,19 @@ private static List manyRandomHearingsWithAllocationDataWithDate(fi .collect(toList()); } + private static List manyRandomHearingsWithAllocationDataWithDate(final Integer numberOfHearings, + final CaseAndDefendantData caseAndDefendantData, + final UUID courtCentreId, + final UUID courtRoomId, + final LocalDate hearingEndDate, + final ZonedDateTime hearingStartTime, + final HearingTypeData hearingTypeData) { + + return IntStream.range(0, numberOfHearings) + .mapToObj((int i) -> randomHearing(hearingStartTime, hearingEndDate, courtCentreId, courtRoomId, singletonList(randomJudicialRole()), caseAndDefendantData, hearingTypeData)) + .collect(toList()); + } + private static List manyRandomHearingsWithUnAllocationData(final Integer numberOfHearings, final CaseAndDefendantData caseAndDefendantData) { final LocalDate hearingEndDate = ItClock.today().plusDays(1); @@ -1416,6 +1445,22 @@ private static HearingData randomHearing(final ZonedDateTime hearingStartTime, singletonList(randomCourtApplicationPartyNeed()), "Carmarthen Magistrates Court"); } + /** SPRDT-1164: same as above but with a caller-supplied hearing type, so the seeded hearing's + * viewstore type_id can be pinned to a specific value (e.g. Trial vs Plea). */ + private static HearingData randomHearing(final ZonedDateTime hearingStartTime, + final LocalDate hearingEndDate, + final UUID courtCentreId, final UUID courtRoomId, final List judicialRoles, + final CaseAndDefendantData caseAndDefendantData, final HearingTypeData hearingTypeData) { + final List listedCaseData = manyRandomListingCases(1, caseAndDefendantData); + final LocalDate hearingStartDate = hearingStartTime.toLocalDate(); + return new HearingData(caseAndDefendantData.getHearingId(), courtCentreId, hearingTypeData, hearingStartDate, + hearingEndDate, HEARING_ESTIMATE_MINUTES, ESTIMATED_DURATION, + courtRoomId, hearingStartTime, listedCaseData, + judicialRoles, caseAndDefendantData.getJurisdictionType(), STRING.next(), + singletonList(randomCourtApplicationData(listedCaseData.get(0).getCaseId())), + singletonList(randomCourtApplicationPartyNeed()), "Carmarthen Magistrates Court"); + } + private static HearingData randomHearingWithHearingDate(final UUID courtCentreId, final UUID courtRoomId, final List judicialRoles, final String jurisdictionType, final ZonedDateTime hearingStartTime, diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java index b9e0a8cf1..e6b326700 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java @@ -568,6 +568,38 @@ public static void stubGetHearingIds(boolean isEmpty) { )); } + /** + * Stubs the courtscheduler get-hearing-ids endpoint with a caller-supplied body, matching any query + * params (only the endpoint + get.hearing.ids Accept header). Lets a test drive the exact + * (hearingId, courtScheduleId, hearingDate, hearingDayCount, hearingDayPosition) tuples returned to + * the court-calendar PATH A enrichment. + */ + public static void stubGetHearingIdsWithBody(final String body) { + stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) + .withHeader("Accept", containing("application/vnd.courtscheduler.get.hearing.ids+json")) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withBody(body) + .withHeader(CONTENT_TYPE, APPLICATION_JSON) + )); + } + + /** + * SPRDT-1164: stub the courtscheduler get-hearing-ids endpoint scoped to a specific courtSession + * (AM/AD), so two different courtSession queries in the same test can be driven to two different + * bodies (e.g. one hearing's day-tuples for AD, another hearing's for AM). isDraft and pageSize are + * left unconstrained so this still matches production's court-calendar allocated path, which now + * sends isDraft=false. + */ + public static void stubGetHearingIdsWithBody(final String courtSession, final String body) { + stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) + .withHeader("Accept", containing("application/vnd.courtscheduler.get.hearing.ids+json")) + .withQueryParam("courtSession", WireMock.equalTo(courtSession)) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withBody(body) + .withHeader(CONTENT_TYPE, APPLICATION_JSON) + )); + } + public static void stubGetHearingIds(final Instant exactHearingStartDateTime) { stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) .withQueryParam("sessionStartDate", matching("1900-01-01")) diff --git a/listing-query/listing-query-view/src/main/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQuery.java b/listing-query/listing-query-view/src/main/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQuery.java index 0b07c8cb3..39b952638 100644 --- a/listing-query/listing-query-view/src/main/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQuery.java +++ b/listing-query/listing-query-view/src/main/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQuery.java @@ -124,7 +124,7 @@ public JsonEnvelope rangeSearchHearings(final JsonEnvelope query) { if (params.courtSessionOptional().isPresent() || params.businessType().isPresent()) { if(isMags(params.jurisdictionType()) && params.allocated() && params.ouCode() != null ) { - return getCourtSchedulerHearings(query, params.allocated(), params.ouCode(), params.courtSessionOptional(), params.courtRoomId(), params.startDate(), params.endDate(), params.exactHearingStartDateTime(), params.businessType(), Optional.ofNullable(params.jurisdictionType()), PANEL_ADULT_YOUTH, params.paginationParameter()); + return getCourtSchedulerHearings(query, params.allocated(), params.ouCode(), params.courtSessionOptional(), params.courtRoomId(), params.startDate(), params.endDate(), params.exactHearingStartDateTime(), params.businessType(), Optional.ofNullable(params.jurisdictionType()), null, null, PANEL_ADULT_YOUTH, params.paginationParameter()); } throw new BadRequestException("courtSession or businessType are only relevant to allocated MAGs with ouCode"); @@ -310,36 +310,54 @@ private List findHearings(final boolean allocated, final String courtCe } - private JsonEnvelope getCourtSchedulerHearings(final JsonEnvelope query, final boolean allocated, final String ouCode, final Optional courtSessionOptional, final String courtRoomId, final String startDate, final String endDate, final Optional startDateTime, final Optional businessType, final Optional jurisdiction, final String panel, final PaginationParameter paginationParameter) { - final HearingIdsResponse hearingIdsResponse = courtSchedulerServiceAdapter.getCourtSchedulerHearings(ouCode, courtSessionOptional, courtRoomId, startDate, endDate, startDateTime, businessType, jurisdiction, panel, paginationParameter.getPageSize(), paginationParameter.getPageNumber()); + private JsonEnvelope getCourtSchedulerHearings(final JsonEnvelope query, final boolean allocated, final String ouCode, final Optional courtSessionOptional, final String courtRoomId, final String startDate, final String endDate, final Optional startDateTime, final Optional businessType, final Optional jurisdiction, final String status, final String hearingTypeId, final String panel, final PaginationParameter paginationParameter) { + final HearingIdsResponse hearingIdsResponse = courtSchedulerServiceAdapter.getCourtSchedulerHearings(ouCode, courtSessionOptional, courtRoomId, startDate, endDate, startDateTime, businessType, jurisdiction, status, panel, paginationParameter.getPageSize(), paginationParameter.getPageNumber()); logger.info("CourtScheduler Hearings response : {}", hearingIdsResponse); + // hearingType is held only in the listing viewstore (not courtscheduler), so it is filtered + // here during enrichment. Draft exclusion is applied courtscheduler-side via the status=FINAL + // filter, so no allocated/draft filter is applied here. Pagination and the result count are + // left exactly as before: the caller's pageSize is forwarded to courtscheduler and its results + // count is passed straight through. final List enrichedHearingList = isEmpty(hearingIdsResponse.getUuids()) - ? emptyList() : enrichAllCourtSchedulerHearingIdsIntoHearings(hearingIdsResponse.getUuids()); + ? emptyList() : enrichAllCourtSchedulerHearingIdsIntoHearings(hearingIdsResponse.getUuids(), hearingTypeId); logger.info("getCourtSchedulerHearings found {} hearings", hearingIdsResponse.getResults()); return buildHearingsResponse(query, allocated, courtRoomId, startDate, enrichedHearingList, hearingIdsResponse.getResults(), hearingIdsResponse, paginationParameter); } - private List enrichAllCourtSchedulerHearingIdsIntoHearings(final List hearingIds) { + private List enrichAllCourtSchedulerHearingIdsIntoHearings(final List hearingIds, final String hearingTypeId) { - final List hearings = repository.findAllCourtSchedulerHearingByIds(hearingIds.stream().map(a -> a.hearingId()).toList()); + // De-duplicate hearing ids before hitting the listing viewstore: a multi-day hearing arrives + // as one IdResponse per day, all sharing the same hearingId. + final List distinctHearingIds = hearingIds.stream().map(IdResponse::hearingId).distinct().toList(); + final List hearings = repository.findAllCourtSchedulerHearingByIds(distinctHearingIds); final HashMap hearingsById = new HashMap<>(); hearings.forEach(h -> hearingsById.put(h.getId(), h)); final List enrichedHearings = new ArrayList<>(); hearingIds.forEach(id -> { - final Hearing hearing = hearingsById.get(id.hearingId()); - if (hearing != null) { - hearing.setHearingDate(id.hearingDate()); - hearing.setHearingDayCount(id.hearingDayCount()); - hearing.setHearingDayPosition(id.hearingDayPosition()); - enrichedHearings.add(hearing); + final Hearing base = hearingsById.get(id.hearingId()); + // hearingType is not known to courtscheduler, so it is filtered listing-side against the + // viewstore. Draft/allocated filtering is handled courtscheduler-side (the isDraft flag). + if (base != null && matchesHearingType(base, hearingTypeId)) { + // Give each hearing-day its own Hearing (deep-copied properties) so a multi-day + // hearing renders one distinct row per day rather than collapsing onto the last day. + final Hearing perDay = new Hearing(base); + perDay.setHearingDate(id.hearingDate()); + perDay.setHearingDayCount(id.hearingDayCount()); + perDay.setHearingDayPosition(id.hearingDayPosition()); + enrichedHearings.add(perDay); } }); return enrichedHearings; } + private static boolean matchesHearingType(final Hearing hearing, final String hearingTypeId) { + return hearingTypeId == null + || (hearing.getTypeId() != null && hearingTypeId.equals(hearing.getTypeId().toString())); + } + private boolean isMags(final String jurisdictionType) { return JurisdictionType.MAGISTRATES.name().equalsIgnoreCase(jurisdictionType); } @@ -358,7 +376,7 @@ public JsonEnvelope rangeSearchCourtCalendar(final JsonEnvelope query) { if (params.courtSessionOptional().isPresent() || params.businessType().isPresent()) { if(params.allocated() && params.ouCode() != null ) { - return getCourtSchedulerHearings(query, params.allocated(), params.ouCode(), params.courtSessionOptional(), params.courtRoomId(), params.startDate(), params.endDate(), params.exactHearingStartDateTime(), params.businessType(), Optional.ofNullable(params.jurisdictionType()), PANEL_ADULT_YOUTH, params.paginationParameter()); + return getCourtSchedulerHearings(query, params.allocated(), params.ouCode(), params.courtSessionOptional(), params.courtRoomId(), params.startDate(), params.endDate(), params.exactHearingStartDateTime(), params.businessType(), Optional.ofNullable(params.jurisdictionType()), params.allocated() ? "FINAL" : null, params.hearingTypeId(), PANEL_ADULT_YOUTH, params.paginationParameter()); } } diff --git a/listing-query/listing-query-view/src/test/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQueryTest.java b/listing-query/listing-query-view/src/test/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQueryTest.java index c44522cb6..36ead3316 100644 --- a/listing-query/listing-query-view/src/test/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQueryTest.java +++ b/listing-query/listing-query-view/src/test/java/uk/gov/moj/cpp/listing/query/view/RangeSearchQueryTest.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; @@ -265,6 +266,10 @@ public void searchHearingsWithDateRangeWithAllParametersProvided() { @Test public void searchMagistratesHearings() { final List hearings = hearingsJson(ALLOCATEDSTR); + // hearingTypeId is supplied on the query below and is now enforced listing-side against + // the viewstore typeId (matchesHearingType), so the mocked hearings must carry a matching + // typeId or they would be filtered out of the enriched result. + hearings.forEach(hearing -> hearing.setTypeId(HEARING_TYPE_ID)); final List hearingIds = new ArrayList<>(); hearings.forEach(hearing -> hearingIds.add(new IdResponse(hearing.getId(), UUID.randomUUID(), LocalDate.now(), 1,1))); @@ -280,6 +285,7 @@ public void searchMagistratesHearings() { Optional.empty(), Optional.of(BUSINESS_TYPE), Optional.of(MAGISTRATES_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 50, 1)).thenReturn(response); when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(hearings); @@ -765,6 +771,7 @@ void rangeSearchCourtCalendarWithCrownAllocatedAndOuCodeAndBusinessTypeShouldUse Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1 @@ -800,6 +807,7 @@ void rangeSearchCourtCalendarWithCrownAllocatedAndOuCodeAndBusinessTypeShouldUse Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1); @@ -821,6 +829,7 @@ void rangeSearchCourtCalendarWithCrownAllocatedAndOuCodeAndHearingTypeIdShouldPa Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1 @@ -857,11 +866,286 @@ void rangeSearchCourtCalendarWithCrownAllocatedAndOuCodeAndHearingTypeIdShouldPa Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1); } + @Test + void rangeSearchCourtCalendarShouldSplitMultiDayCrownHearingIntoDistinctPerDayRows() { + final UUID multiDayId = randomUUID(); + final String props = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [" + + "{\"hearingDate\": \"2026-07-14\"}, {\"hearingDate\": \"2026-07-15\"}, {\"hearingDate\": \"2026-07-16\"}] }"; + final Hearing multiDay = new Hearing(multiDayId, JacksonUtil.toJsonNode(props)); + multiDay.setAllocated(true); + + final List hearingIds = new ArrayList<>(); + hearingIds.add(new IdResponse(multiDayId, randomUUID(), LocalDate.parse("2026-07-14"), 3, 1)); + hearingIds.add(new IdResponse(multiDayId, randomUUID(), LocalDate.parse("2026-07-15"), 3, 2)); + hearingIds.add(new IdResponse(multiDayId, randomUUID(), LocalDate.parse("2026-07-16"), 3, 3)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(multiDay)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + final JsonObject payload = rangeSearchQuery.rangeSearchCourtCalendar(query).payloadAsJsonObject(); + final JsonArray hearings = payload.getJsonArray("hearings"); + + final java.util.Set dates = new java.util.HashSet<>(); + for (int i = 0; i < hearings.size(); i++) { + dates.add(hearings.getJsonObject(i).getJsonArray("hearingDays").getJsonObject(0).getString("hearingDate")); + } + assertThat(hearings.size(), is(3)); + assertThat(dates.size(), is(3)); // aliasing bug collapses all rows onto the last day -> size 1 + assertThat(dates.contains("2026-07-14") && dates.contains("2026-07-15") && dates.contains("2026-07-16"), is(true)); + assertThat(payload.getInt("results"), is(3)); + } + + @Test + void rangeSearchCourtCalendarShouldExcludeDraftUnallocatedHearingsFromAllocatedView() { + // Draft (unallocated) exclusion moved from listing-side enrich to the courtscheduler call + // itself: the allocated court-calendar view now requests status=FINAL so drafts never + // come back in the hearingId list to begin with. This test pins that contract at the + // adapter-invocation boundary rather than asserting listing-side enrich drops anything + // (enrich no longer filters on allocated at all - see matchesHearingType). + final UUID allocatedId = randomUUID(); + final String allocatedProps = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [{\"hearingDate\": \"2026-07-14\"}] }"; + final Hearing allocated = new Hearing(allocatedId, JacksonUtil.toJsonNode(allocatedProps)); + allocated.setAllocated(true); + + final List hearingIds = new ArrayList<>(); + hearingIds.add(new IdResponse(allocatedId, randomUUID(), LocalDate.parse("2026-07-14"), 1, 1)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(allocated)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + rangeSearchQuery.rangeSearchCourtCalendar(query); + + verify(courtSchedulerServiceAdapter).getCourtSchedulerHearings( + eq("C01CY00"), any(), any(), any(), any(), any(), any(), any(), eq("FINAL"), any(), any(), any()); + } + + @Test + void rangeSearchCourtCalendarAllocatedShouldRequestIsDraftFalseFromCourtScheduler() { + final List mockHearings = hearingsJson(ALLOCATEDSTR); + final List hearingIds = new ArrayList<>(); + mockHearings.forEach(h -> hearingIds.add(new IdResponse(h.getId(), UUID.randomUUID(), LocalDate.now(), 1, 1))); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, mockHearings.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(mockHearings); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(START_DATE_QUERY_PARAMETER, SEARCH_DATE.toString()) + .add(END_DATE_QUERY_PARAMETER, SEARCH_DATE.toString()) + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + rangeSearchQuery.rangeSearchCourtCalendar(query); + + // allocated=true on the court-calendar view must always ask courtscheduler to exclude drafts. + verify(courtSchedulerServiceAdapter).getCourtSchedulerHearings( + eq("C01CY00"), any(), any(), any(), any(), any(), any(), any(), eq("FINAL"), any(), any(), any()); + } + + @Test + void rangeSearchCourtCalendarWithMatchingHearingTypeIdShouldReturnHearing() { + final UUID hearingId = randomUUID(); + final String props = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [{\"hearingDate\": \"2026-07-14\"}] }"; + final Hearing hearing = new Hearing(hearingId, JacksonUtil.toJsonNode(props)); + hearing.setAllocated(true); + hearing.setTypeId(HEARING_TYPE_ID); + + final List hearingIds = List.of(new IdResponse(hearingId, randomUUID(), LocalDate.parse("2026-07-14"), 1, 1)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(hearing)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(HEARING_TYPE_QUERY_PARAMETER, HEARING_TYPE_ID.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + final JsonObject payload = rangeSearchQuery.rangeSearchCourtCalendar(query).payloadAsJsonObject(); + + assertThat(payload.getJsonArray("hearings").size(), is(1)); + assertThat(payload.getInt("results"), is(1)); + } + + @Test + void rangeSearchCourtCalendarWithNonMatchingHearingTypeIdShouldFilterOutHearing() { + final UUID hearingId = randomUUID(); + final String props = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [{\"hearingDate\": \"2026-07-14\"}] }"; + final Hearing hearing = new Hearing(hearingId, JacksonUtil.toJsonNode(props)); + hearing.setAllocated(true); + hearing.setTypeId(randomUUID()); // deliberately different from the queried hearingTypeId + + final List hearingIds = List.of(new IdResponse(hearingId, randomUUID(), LocalDate.parse("2026-07-14"), 1, 1)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(hearing)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(HEARING_TYPE_QUERY_PARAMETER, HEARING_TYPE_ID.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + final JsonObject payload = rangeSearchQuery.rangeSearchCourtCalendar(query).payloadAsJsonObject(); + + assertThat(payload.getJsonArray("hearings").size(), is(0)); + assertThat(payload.getInt("results"), is(1)); // courtscheduler's raw count is unfiltered; only the enriched rows drop the mismatch + } + + @Test + void rangeSearchCourtCalendarWithNoHearingTypeIdShouldNotFilterByType() { + final UUID hearingId1 = randomUUID(); + final UUID hearingId2 = randomUUID(); + final String props = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [{\"hearingDate\": \"2026-07-14\"}] }"; + final Hearing hearing1 = new Hearing(hearingId1, JacksonUtil.toJsonNode(props)); + hearing1.setAllocated(true); + hearing1.setTypeId(HEARING_TYPE_ID); + final Hearing hearing2 = new Hearing(hearingId2, JacksonUtil.toJsonNode(props)); + hearing2.setAllocated(true); + hearing2.setTypeId(randomUUID()); + + final List hearingIds = List.of( + new IdResponse(hearingId1, randomUUID(), LocalDate.parse("2026-07-14"), 1, 1), + new IdResponse(hearingId2, randomUUID(), LocalDate.parse("2026-07-14"), 1, 1)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(hearing1, hearing2)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + final JsonObject payload = rangeSearchQuery.rangeSearchCourtCalendar(query).payloadAsJsonObject(); + + assertThat(payload.getJsonArray("hearings").size(), is(2)); + } + + @Test + void rangeSearchCourtCalendarShouldDedupeMultiDayHearingIdsBeforeFetchingFromViewstore() { + final UUID multiDayId = randomUUID(); + final String props = "{ \"allocated\":\"true\", \"startDate\": \"2026-07-14\", \"courtRoomId\": \"" + COURT_ROOM_ID + + "\", \"courtApplications\": [{}], \"listedCases\": [{}], \"hearingDays\": [" + + "{\"hearingDate\": \"2026-07-14\"}, {\"hearingDate\": \"2026-07-15\"}] }"; + final Hearing multiDay = new Hearing(multiDayId, JacksonUtil.toJsonNode(props)); + multiDay.setAllocated(true); + + final List hearingIds = new ArrayList<>(); + hearingIds.add(new IdResponse(multiDayId, randomUUID(), LocalDate.parse("2026-07-14"), 2, 1)); + hearingIds.add(new IdResponse(multiDayId, randomUUID(), LocalDate.parse("2026-07-15"), 2, 2)); + final HearingIdsResponse response = new HearingIdsResponse(hearingIds, hearingIds.size(), 1); + + when(courtSchedulerServiceAdapter.getCourtSchedulerHearings( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(newArrayList(multiDay)); + + final JsonEnvelope query = envelopeFrom( + metadataBuilder().withId(randomUUID()).withName("event.name"), + createObjectBuilder() + .add(ALLOCATED_QUERY_PARAMETER, true) + .add(COURT_SESSION_QUERY_PARAMETER, "AD") + .add(COURT_CENTRE_QUERY_PARAMETER, COURT_CENTRE_ID.toString()) + .add(JURISDICTION_TYPE_QUERY_PARAMETER, JURISDICTION_TYPE.toString()) + .add(START_DATE_QUERY_PARAMETER, "2026-07-14") + .add(END_DATE_QUERY_PARAMETER, "2026-07-17") + .add(OU_CODE_QUERY_PARAMETER, "C01CY00") + .add(PAGE_SIZE, 40) + .add(PAGE_NUMBER, 1) + .build()); + + final JsonObject payload = rangeSearchQuery.rangeSearchCourtCalendar(query).payloadAsJsonObject(); + + // 2 IdResponses share the same hearingId (a 2-day multiday hearing) -> the repository must + // be asked for only 1 distinct id, while the response still carries one row per day. + verify(hearingRepository).findAllCourtSchedulerHearingByIds(argThat(ids -> ids.size() == 1)); + assertThat(payload.getJsonArray("hearings").size(), is(2)); + } + // ----------------------------------------------------------------------- // rangeSearchCourtCalendar – missing branch coverage // ----------------------------------------------------------------------- @@ -882,6 +1166,7 @@ void rangeSearchCourtCalendarWithCrownAndBusinessTypeShouldUseCourtScheduler() { Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1 @@ -918,6 +1203,7 @@ void rangeSearchCourtCalendarWithCrownAndBusinessTypeShouldUseCourtScheduler() { Optional.empty(), Optional.of("GENC"), Optional.of(JURISDICTION_TYPE.toString()), + "FINAL", "ADULT,YOUTH", 40, 1); @@ -1204,6 +1490,10 @@ void rangeSearchHearingsWithMagsAllocatedOuCodeCourtSessionAndBusinessTypeShould Optional.empty(), Optional.of(BUSINESS_TYPE), Optional.of(MAGISTRATES_TYPE.toString()), + // The plain (non-calendar) range-search path always passes status=null - draft + // exclusion is a court-calendar-only concern - so the adapter stub must match null + // here, not "FINAL". + null, "ADULT,YOUTH", 50, 1) ).thenReturn(response); when(hearingRepository.findAllCourtSchedulerHearingByIds(anyList())).thenReturn(hearings); diff --git a/listing-viewstore/listing-viewstore-persistence/src/main/java/uk/gov/moj/cpp/listing/persistence/entity/Hearing.java b/listing-viewstore/listing-viewstore-persistence/src/main/java/uk/gov/moj/cpp/listing/persistence/entity/Hearing.java index c47c9aa38..e7aaca400 100644 --- a/listing-viewstore/listing-viewstore-persistence/src/main/java/uk/gov/moj/cpp/listing/persistence/entity/Hearing.java +++ b/listing-viewstore/listing-viewstore-persistence/src/main/java/uk/gov/moj/cpp/listing/persistence/entity/Hearing.java @@ -125,6 +125,40 @@ public Hearing(final UUID id, final JsonNode properties) { } + /** + * Copy constructor. Produces an independent {@link Hearing} carrying the same data as + * {@code source}, with a DEEP COPY of {@code properties}. This matters for the court-calendar + * courtscheduler path: a multi-day hearing is returned once per hearing-day and each day must + * render as its own row. Per-row flattening mutates the {@code properties} JsonNode in place, so + * sharing one node across the day-copies would let one row corrupt the others. Collections are + * defensively copied; the remaining fields are immutable value types safe to share. + */ + public Hearing(final Hearing source) { + this.id = source.id; + this.courtCentreId = source.courtCentreId; + this.courtRoomId = source.courtRoomId; + this.isVacatedTrial = source.isVacatedTrial; + this.unscheduled = source.unscheduled; + this.typeId = source.typeId; + this.jurisdictionType = source.jurisdictionType; + this.weekCommencingStartDate = source.weekCommencingStartDate; + this.weekCommencingEndDate = source.weekCommencingEndDate; + this.startDate = source.startDate; + this.endDate = source.endDate; + this.hearingDays = source.hearingDays == null ? new HashSet<>() : new HashSet<>(source.hearingDays); + this.listedCases = source.listedCases == null ? new HashSet<>() : new HashSet<>(source.listedCases); + this.allocated = source.allocated; + this.courtApplications = source.courtApplications == null ? new HashSet<>() : new HashSet<>(source.courtApplications); + this.typeOfListId = source.typeOfListId; + this.properties = source.properties == null ? null : source.properties.deepCopy(); + this.estimatedMinutes = source.estimatedMinutes; + this.totalCount = source.totalCount; + this.isPossibleDisqualification = source.isPossibleDisqualification; + this.hearingDayCount = source.hearingDayCount; + this.hearingDayPosition = source.hearingDayPosition; + this.hearingDate = source.hearingDate; + } + public Hearing(final UUID id, final UUID courtCentreId, final UUID courtRoomId,