From 543d0d2fb131eb8671a71e531b3c0976cc2d56ee Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 25 Jun 2026 00:55:42 +0100 Subject: [PATCH 01/27] feat(SPRDT-1011): migrate courtscheduler calls to reshaped contract Update listing's courtscheduler client to the Phase-1 reshaped API: - search-by-id: GET /courtschedule/search.court-schedules-by-id -> GET /sessions; query param courtScheduleIds -> ids - release slots: DELETE /hearingslots/{id} -> DELETE /sessions/{id}; action remove.hearing.slots -> release.sessions - list hearings: PUT /list/hearingslots -> POST /hearings; action list.hearings-in-court-sessions -> list.hearings-in-sessions - extend multiday: POST /extendmultidayhearing/hearingslots -> PATCH /hearings/{hearingId} (new HttpPatch helper) - search-and-book (mags/crown/crown-fallback): GET query endpoints -> POST /hearings/{hearingId} with typed JSON body, actions mags.search.and.book / crown.search.and.book Response parsing: mags searchAndBook now reads sessions[0] (was a hearingSlots object); multiday reads sessions[] (was courtSchedules[]). Crown-fallback parser unchanged - matched by the courtscheduler single-day flat-field change. Other responses serialise identical domain objects, so their parsing is unchanged. --- .../CourtScheduleEnrichmentService.java | 36 ++-- .../service/CourtSchedulerServiceAdapter.java | 2 +- .../common/service/HearingSlotsService.java | 156 +++++++++++++++--- 3 files changed, 157 insertions(+), 37 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 7abc089fe..0088a5708 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -60,7 +60,7 @@ @SuppressWarnings("java:S3776") public class CourtScheduleEnrichmentService implements EnrichmentService { private static final String HEARING_SLOTS = "hearingSlots"; - private static final String COURT_SCHEDULE_IDS = "courtScheduleIds"; + private static final String COURT_SCHEDULE_IDS = "ids"; private static final String JUDICIARIES = "judiciaries"; private static final String COURT_SCHEDULE_ID = "courtScheduleId"; private static final String IS_DRAFT = "isDraft"; @@ -971,7 +971,7 @@ private List multiDaySearchAndBook(final String courtScheduleId, return new ArrayList<>(); } - final JsonArray schedulesArray = responseJson.getJsonArray(COURT_SCHEDULES); + final JsonArray schedulesArray = responseJson.getJsonArray("sessions"); if (schedulesArray == null || schedulesArray.isEmpty()) { return new ArrayList<>(); } @@ -1220,22 +1220,36 @@ protected HearingSlotSearchResponse searchAndBookSlots(final String hearingId, final Response searchAndBookResponse = hearingSlotsService.searchBookSlots(queryParams); if (HttpStatus.SC_OK == searchAndBookResponse.getStatus()) { - final JsonObject responseJson = objectToJsonObjectConverter.convert(searchAndBookResponse.getEntity()).getJsonObject(HEARING_SLOTS); + final JsonObject responseJson = objectToJsonObjectConverter.convert(searchAndBookResponse.getEntity()); if (responseJson == null || responseJson.isEmpty()) { LOGGER.error("searchAndBookResponse from listingCourtScheduler returned an empty response for params : {} ", queryParams); return null; } - final String bookedHearingId = responseJson.getString(HEARING_ID); - final String bookedCourtScheduleId = responseJson.getString(COURT_SCHEDULE_ID); - final String bookedCourtRoomId = responseJson.getString(COURT_ROOM_ID); - final String bookedSessionStartTime = responseJson.getString(HEARING_START_TIME); - final Integer duration = responseJson.getInt("duration"); - final Boolean isDraft = responseJson.containsKey(IS_DRAFT) && responseJson.getBoolean(IS_DRAFT); + final String bookedHearingId = responseJson.containsKey(HEARING_ID) && !responseJson.isNull(HEARING_ID) + ? responseJson.getString(HEARING_ID) : null; + final JsonArray sessionsArray = responseJson.getJsonArray("sessions"); + if (sessionsArray == null || sessionsArray.isEmpty()) { + LOGGER.error("searchAndBookResponse from listingCourtScheduler returned no sessions for params : {} ", queryParams); + return null; + } + final JsonObject sessionJson = sessionsArray.getJsonObject(0); + final String bookedCourtScheduleId = sessionJson.containsKey(COURT_SCHEDULE_ID) && !sessionJson.isNull(COURT_SCHEDULE_ID) + ? sessionJson.getString(COURT_SCHEDULE_ID) : null; + final String bookedCourtRoomId = sessionJson.containsKey(COURT_ROOM_ID) && !sessionJson.isNull(COURT_ROOM_ID) + ? sessionJson.getString(COURT_ROOM_ID) : null; + // Wire emits "sessionStartTime" (CourtSchedule.sessionStartTime) for the booked session + final String bookedSessionStartTime = sessionJson.containsKey("sessionStartTime") && !sessionJson.isNull("sessionStartTime") + ? sessionJson.getString("sessionStartTime") : null; + // Duration is not in the CourtSchedule element; use durationInMinutes from the request + final Integer duration = durationInMinutes; + // Wire emits "draft" (Jackson strips is- from isDraft getter) + final Boolean isDraft = sessionJson.containsKey("draft") ? sessionJson.getBoolean("draft") + : (sessionJson.containsKey(IS_DRAFT) ? sessionJson.getBoolean(IS_DRAFT) : Boolean.FALSE); // Extract judiciaries if present List judiciaries = new ArrayList<>(); - if (responseJson.containsKey(JUDICIARIES)) { - final JsonArray judiciariesArray = responseJson.getJsonArray(JUDICIARIES); + if (sessionJson.containsKey(JUDICIARIES)) { + final JsonArray judiciariesArray = sessionJson.getJsonArray(JUDICIARIES); if (judiciariesArray != null && !judiciariesArray.isEmpty()) { for (int i = 0; i < judiciariesArray.size(); i++) { JsonObject judicialRoleJson = judiciariesArray.getJsonObject(i); 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 1e50ed164..d52d95c56 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 @@ -337,7 +337,7 @@ public JsonObject getCourtScheduleDraftStatus(final JsonObject requestPayload) { } final Map params = new HashMap<>(); - params.put("courtScheduleIds", String.join(",", courtScheduleIds)); + params.put("ids", String.join(",", courtScheduleIds)); final Response response; try { diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index fc4682c2d..2b6c1e79b 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -27,8 +27,8 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.StringEntity; @@ -45,25 +45,19 @@ public class HearingSlotsService { public static final String HEARING_DATE = "hearingDate"; private static final String HEARING_RESOURCE = "/hearingslots"; + private static final String SESSIONS_RESOURCE = "/sessions"; + private static final String HEARINGS_RESOURCE = "/hearings"; private static final String VALIDATE_SESSION_AVAILABILITY_RESOURCE = "/validate-session-availability"; - private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE = "/list/hearingslots"; - private static final String HEARING_SEARCH_BOOK_RESOURCE = "/searchlist/hearingslots"; - private static final String COURTSCHEDULES_RESOURCE = "/courtschedule/search.court-schedules-by-id"; - private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS = "application/vnd.courtscheduler.list.hearings-in-court-sessions+json"; + private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE = HEARINGS_RESOURCE; + private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS = "application/vnd.courtscheduler.list.hearings-in-sessions+json"; private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json"; private static final String COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID = "application/vnd.courtscheduler.search.court-schedules-by-id+json"; - private static final String COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.remove.hearing.slots+json"; + private static final String COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.release.sessions+json"; private static final String COUTRT_SCHEDULER_HEARING_IDS = "application/vnd.courtscheduler.get.hearing.ids+json"; - private static final String COURTSCHEDULER_SEARCH_BOOK_COURTSCHEDULES = "application/vnd.courtscheduler.search.book.hearing.slots+json"; + private static final String COURTSCHEDULER_MAGS_SEARCH_BOOK = "application/vnd.courtscheduler.mags.search.and.book+json"; + private static final String COURTSCHEDULER_CROWN_SEARCH_BOOK = "application/vnd.courtscheduler.crown.search.and.book+json"; private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json"; - private static final String MULTIDAY_SEARCH_BOOK_RESOURCE = "/multidaysearchandbook/hearingslots"; - private static final String COURTSCHEDULER_MULTIDAY_SEARCH_BOOK = "application/vnd.courtscheduler.multiday.searchandbook.hearing.slots+json"; - - private static final String CROWN_FALLBACK_SEARCH_BOOK_RESOURCE = "/crownfallbacksearchandbook/hearingslots"; - private static final String COURTSCHEDULER_CROWN_FALLBACK_SEARCH_BOOK = "application/vnd.courtscheduler.crown.fallback.search.book.hearing.slots+json"; - - private static final String EXTEND_MULTIDAY_RESOURCE = "/extendmultidayhearing/hearingslots"; private static final String COURTSCHEDULER_EXTEND_MULTIDAY = "application/vnd.courtscheduler.extend.multiday.hearing+json"; private static final String CJS_CPP_UID = "CJSCPPUID"; @@ -86,11 +80,12 @@ public Response validateSessionAvailability(final JsonObject payload) { } public Response extendMultiDayHearing(final JsonObject payload) { - return post(EXTEND_MULTIDAY_RESOURCE, COURTSCHEDULER_EXTEND_MULTIDAY, payload, false); + final String hearingId = payload.getString("hearingId"); + return patch(HEARINGS_RESOURCE + "/" + hearingId, COURTSCHEDULER_EXTEND_MULTIDAY, payload); } public Response searchBookSlots(final Map params) { - return query(HEARING_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_SEARCH_BOOK_COURTSCHEDULES, params); + return postSearchBook(COURTSCHEDULER_MAGS_SEARCH_BOOK, params); } public Response listHearingInCourtSessions(final Object payload) { @@ -99,14 +94,14 @@ public Response listHearingInCourtSessions(final Object payload) { } try { - final HttpPut httpPut = new HttpPut(new URIBuilder(baseUri + COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE).build()); - httpPut.addHeader(CONTENT_TYPE, COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS); - httpPut.addHeader(CJS_CPP_UID, getUserId().toString()); + final HttpPost httpPost = new HttpPost(new URIBuilder(baseUri + COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE).build()); + httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); final StringEntity requestEntity = new StringEntity(this.objectMapper.writeValueAsString(payload)); - httpPut.setEntity(requestEntity); + httpPost.setEntity(requestEntity); - final HttpResponse httpResponse = execute(httpPut); + final HttpResponse httpResponse = execute(httpPost); if (isOk(httpResponse)) { if (LOGGER.isInfoEnabled()) { @@ -135,15 +130,15 @@ public Response listHearingInCourtSessions(final Object payload) { } public Response getCourtSchedulesById(final Map params) { - return query(COURTSCHEDULES_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params); + return query(SESSIONS_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params); } public Response multiDaySearchAndBook(final Map params) { - return query(MULTIDAY_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_MULTIDAY_SEARCH_BOOK, params); + return postSearchBook(COURTSCHEDULER_CROWN_SEARCH_BOOK, params); } public Response crownFallbackSearchAndBook(final Map params) { - return query(CROWN_FALLBACK_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_CROWN_FALLBACK_SEARCH_BOOK, params); + return postSearchBook(COURTSCHEDULER_CROWN_SEARCH_BOOK, params); } public void delete(final UUID hearingId) { @@ -152,7 +147,7 @@ public void delete(final UUID hearingId) { } try { - final HttpDelete httpDelete = new HttpDelete(new URIBuilder(baseUri + HEARING_RESOURCE + "/" + hearingId).build()); + final HttpDelete httpDelete = new HttpDelete(new URIBuilder(baseUri + SESSIONS_RESOURCE + "/" + hearingId).build()); httpDelete.addHeader(CONTENT_TYPE, COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE); httpDelete.addHeader(CJS_CPP_UID, getUserId().toString()); @@ -280,4 +275,115 @@ private Response post(final String urlPath, final String contentTypeHeader, fina .build(); } } + + private Response patch(final String urlPath, final String contentTypeHeader, final JsonObject payload) { + if (LOGGER.isInfoEnabled() && Objects.nonNull(payload)) { + LOGGER.info("{} PATCH in CourtScheduler S & L with payload '{}'", contentTypeHeader, payload); + } + if (payload == null || payload.isEmpty()) { + throw new DataValidationException("Payload for PATCH %s is null or empty ....".formatted(contentTypeHeader)); + } + try { + final HttpPatch httpPatch = new HttpPatch(new URIBuilder(baseUri + urlPath).build()); + httpPatch.addHeader(CONTENT_TYPE, contentTypeHeader); + httpPatch.addHeader(CJS_CPP_UID, getUserId().toString()); + httpPatch.setEntity(new StringEntity(payload.toString())); + + final HttpResponse httpResponse = execute(httpPatch); + final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + final Object entity = responseBody == null || responseBody.isBlank() + ? Json.createObjectBuilder().build() + : stringToJsonObjectConverter.convert(responseBody); + + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + if (isOk(httpResponse)) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("PATCH {} successfully", contentTypeHeader); + } + } else { + LOGGER.error("PATCH {} failed with status code:{}", contentTypeHeader, statusCode); + } + return Response + .status(statusCode) + .entity(entity) + .build(); + } catch (URISyntaxException | IOException ex) { + LOGGER.error("Exception thrown on trying to PATCH %s".formatted(contentTypeHeader), ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } + } + + /** + * Posts a search-and-book request to /hearings/{hearingId} with a typed JSON body. + * Extracts "hearingId" from params map for the path; builds remaining params as a JSON body, + * converting numeric fields (durationInMinutes) to numbers and boolean fields (isPolice) to booleans. + */ + private Response postSearchBook(final String contentTypeHeader, final Map params) { + if (params == null) { + throw new DataValidationException("Params for %s is null ....".formatted(contentTypeHeader)); + } + final String hearingId = params.get("hearingId"); + if (hearingId == null || hearingId.isBlank()) { + throw new DataValidationException("hearingId missing from params for %s".formatted(contentTypeHeader)); + } + + // Build typed JSON body from params (hearingId always included) + final javax.json.JsonObjectBuilder bodyBuilder = Json.createObjectBuilder(); + params.forEach((key, value) -> { + if (value == null) { + return; + } + if ("durationInMinutes".equals(key)) { + try { + bodyBuilder.add(key, Integer.parseInt(value)); + } catch (NumberFormatException e) { + bodyBuilder.add(key, value); + } + } else if ("isPolice".equals(key)) { + bodyBuilder.add(key, Boolean.parseBoolean(value)); + } else { + bodyBuilder.add(key, value); + } + }); + final JsonObject payload = bodyBuilder.build(); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("{} POST /hearings/{} in CourtScheduler S & L with payload '{}'", contentTypeHeader, hearingId, payload); + } + + try { + final HttpPost httpPost = new HttpPost(new URIBuilder(baseUri + HEARINGS_RESOURCE + "/" + hearingId).build()); + httpPost.addHeader(CONTENT_TYPE, contentTypeHeader); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); + httpPost.setEntity(new StringEntity(payload.toString())); + + final HttpResponse httpResponse = execute(httpPost); + final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + final Object entity = responseBody == null || responseBody.isBlank() + ? Json.createObjectBuilder().build() + : stringToJsonObjectConverter.convert(responseBody); + + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + if (isOk(httpResponse)) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("POST {} successfully", contentTypeHeader); + } + } else { + LOGGER.error("POST {} failed with status code:{}", contentTypeHeader, statusCode); + } + return Response + .status(statusCode) + .entity(entity) + .build(); + } catch (URISyntaxException | IOException ex) { + LOGGER.error("Exception thrown on trying to POST %s".formatted(contentTypeHeader), ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } + } } From 75c413984e4dcbb35034d5cb0d2dbc3242bdb049 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 25 Jun 2026 01:29:40 +0100 Subject: [PATCH 02/27] test(SPRDT-1011): align listing tests + IT stubs with reshaped contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CourtSchedulerServiceStub (WireMock): retarget every stub to the new endpoints/actions — GET /sessions, DELETE /sessions/{id} (release.sessions), POST /hearings (list.hearings-in-sessions), PATCH /hearings/{id} (extend.multiday.hearing), POST /hearings/{id} (mags/crown.search.and.book). Multiday vs crown-fallback share the crown.search.and.book endpoint and are disambiguated by a durationInMinutes body matcher (>360 vs <=360). Response bodies reshaped: mags now {hearingId,sessions[]}, multiday courtSchedules-> sessions, crown-fallback flat fields + empty sessions[]. - HearingSlotsServiceTest: expect new URLs/methods/headers; DELETE now 202 ACCEPTED; HttpPatch for extend; POST bodies for search-and-book. - CourtScheduleEnrichmentServiceTest + 3 search-book fixtures: new sessions[] response shape for mags/multiday. - HearingSlotsService: guard extendMultiDayHearing against empty payload (was NPE on getString(hearingId) before the validation guard). Both changed modules green (listing-common 165, listing-command-api 295). --- .../CourtScheduleEnrichmentServiceTest.java | 24 +- ...duler.search.book.hearing.slots.draft.json | 19 +- ...rtscheduler.search.book.hearing.slots.json | 19 +- ...h.book.hearing.slots.with.judiciaries.json | 46 +- .../common/service/HearingSlotsService.java | 3 + .../service/HearingSlotsServiceTest.java | 64 +-- .../it/CrownUpdateHearingMultidayIT.java | 11 +- .../uk/gov/moj/cpp/listing/it/HearingIT.java | 2 +- .../listing/steps/ListCourtHearingSteps.java | 27 +- .../utils/CourtSchedulerServiceStub.java | 481 ++++++++++-------- 10 files changed, 380 insertions(+), 316 deletions(-) diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 0fda77697..19aea8e22 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -668,7 +668,7 @@ void shouldEnrichCrownMultiDayWithConsecutiveSessions() { final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -767,7 +767,7 @@ void shouldNotSetCourtRoomIdOnHearingDays_whenSessionCourtRoomIdIsNull() { cs3.setSessionStartTime(Date.from(day1.plusDays(2).atTime(10, 0).toInstant(ZoneOffset.UTC))); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(JsonObjects.createObjectBuilder().add("courtScheduleId", courtScheduleId1.toString()).build()) .add(JsonObjects.createObjectBuilder().add("courtScheduleId", courtScheduleId2.toString()).build()) .add(JsonObjects.createObjectBuilder().add("courtScheduleId", courtScheduleId3.toString()).build())) @@ -844,7 +844,7 @@ void shouldReturnUnchangedWhenMultiDaySearchReturnsEmpty() { // Mock multiDaySearchAndBook returning empty final JsonObject emptyResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder()) + .add("sessions", JsonObjects.createArrayBuilder()) .build(); final Response multiDayResponse = mock(Response.class); @@ -894,7 +894,7 @@ void shouldNotCallListHearingWhenMultiDaySessionsHaveDraft() { final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -1307,7 +1307,7 @@ void shouldEnrichCrownUpdateMultiDay() { final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2))) .build(); @@ -1393,7 +1393,7 @@ void shouldNotSetCourtRoomIdOnUpdateHearingDays_whenSessionCourtRoomIdIsNull() { cs2.setSessionStartTime(Date.from(day1.plusDays(1).atTime(10, 0).toInstant(ZoneOffset.UTC))); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(JsonObjects.createObjectBuilder().add("courtScheduleId", courtScheduleId1.toString()).build()) .add(JsonObjects.createObjectBuilder().add("courtScheduleId", courtScheduleId2.toString()).build())) .build(); @@ -1457,7 +1457,7 @@ void shouldReturnUnchangedWhenCrownUpdateMultiDaySearchReturnsEmpty() { // Mock multiDaySearchAndBook returning empty final JsonObject emptyResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder()) + .add("sessions", JsonObjects.createArrayBuilder()) .build(); final Response multiDayResponse = mock(Response.class); @@ -1496,7 +1496,7 @@ void shouldNotCallListHearingWhenCrownUpdateMultiDaySessionsHaveDraft() { final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), true); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2))) .build(); @@ -3133,7 +3133,7 @@ void enrichCrownCourtScheduleFirst_shouldCallMultiDay_whenCourtScheduleIdPresent final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -3296,7 +3296,7 @@ void enrichCrownCourtScheduleFirst_shouldSkipListHearing_whenMultiDaySessionsHav final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -3499,7 +3499,7 @@ void enrichCrownCourtScheduleFirst_update_shouldCallMultiDay_whenCourtScheduleId final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2))) .build(); @@ -3585,7 +3585,7 @@ void enrichCrownCourtScheduleFirst_update_shouldCallMultiDay_whenRawPayloadHasCo final CourtSchedule cs3 = buildCourtSchedule(UUID.randomUUID(), courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json index 885436da4..f5b0f1ae6 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json @@ -1,10 +1,13 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": true - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": true, + "businessType": "TRIAL", + "judiciaries": [] + } + ] } diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json index fe96d6abc..dc8752a69 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json @@ -1,10 +1,13 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": false - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": false, + "businessType": "TRIAL", + "judiciaries": [] + } + ] } diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json index 1f64e72d0..8045f24b2 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json @@ -1,24 +1,26 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": false, - "judiciaries": [ - { - "judiciaryId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "judiciaryType": "CIRCUIT_JUDGE", - "benchChairman": true, - "deputy": false - }, - { - "judiciaryId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "judiciaryType": "RECORDER", - "benchChairman": false, - "deputy": true - } - ] - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": false, + "businessType": "TRIAL", + "judiciaries": [ + { + "judiciaryId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "judiciaryType": "CIRCUIT_JUDGE", + "benchChairman": true, + "deputy": false + }, + { + "judiciaryId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "judiciaryType": "RECORDER", + "benchChairman": false, + "deputy": true + } + ] + } + ] } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index 2b6c1e79b..6e821f78c 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -80,6 +80,9 @@ public Response validateSessionAvailability(final JsonObject payload) { } public Response extendMultiDayHearing(final JsonObject payload) { + if (payload == null || payload.isEmpty()) { + throw new DataValidationException("Payload for %s is null or empty ....".formatted(COURTSCHEDULER_EXTEND_MULTIDAY)); + } final String hearingId = payload.getString("hearingId"); return patch(HEARINGS_RESOURCE + "/" + hearingId, COURTSCHEDULER_EXTEND_MULTIDAY, payload); } diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java index a7604f629..ca4a1a75f 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java @@ -24,8 +24,8 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; @@ -64,10 +64,10 @@ class HearingSlotsServiceTest { @Captor private ArgumentCaptor httpGetCaptor; @Captor - private ArgumentCaptor httpPutCaptor; - @Captor private ArgumentCaptor httpDeleteCaptor; @Captor + private ArgumentCaptor httpPatchCaptor; + @Captor private ArgumentCaptor httpPostCaptor; @InjectMocks @@ -115,7 +115,7 @@ void shouldDeleteSuccessfully() throws Exception { when(httpClientBuilder.build()).thenReturn(httpClient); when(httpClient.execute(any(HttpDelete.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); - when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(statusLine.getStatusCode()).thenReturn(Response.Status.ACCEPTED.getStatusCode()); // When hearingSlotsService.delete(TEST_HEARING_ID); @@ -123,7 +123,7 @@ void shouldDeleteSuccessfully() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -236,7 +236,7 @@ void shouldGetCourtSchedulesByIdSuccessfully() throws Exception { assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); verify(httpClient).execute(httpGetCaptor.capture()); HttpGet capturedGet = httpGetCaptor.getValue(); - assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/courtschedule/search.court-schedules-by-id?key=value")); + assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/sessions?key=value")); } } @@ -294,7 +294,7 @@ void shouldHandleDeleteError() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -314,7 +314,7 @@ void shouldHandleDeleteIOException() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -392,28 +392,30 @@ void shouldHandleSystemUserProviderError() { @Test void shouldSearchAndBookSlotsSuccessfully() throws Exception { - // Given + // Given — hearingId is mandatory; other params become the JSON body Map params = new HashMap<>(); - params.put("key", "value"); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("ouCode", "OU123"); when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpGet.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); - when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); - when(stringToJsonObjectConverter.convert(any())).thenReturn(mock(javax.json.JsonObject.class)); + when(httpResponse.getEntity()).thenReturn(null); // When Response response = hearingSlotsService.searchBookSlots(params); // Then assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpGetCaptor.capture()); - HttpGet capturedGet = httpGetCaptor.getValue(); - assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/searchlist/hearingslots?key=value")); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.mags.search.and.book+json")); } } @@ -426,7 +428,7 @@ void shouldThrowExceptionWhenSearchAndBookParamsAreNull() { try { hearingSlotsService.searchBookSlots(params); } catch (DataValidationException e) { - assertThat(e.getMessage(), is("Params for search application/vnd.courtscheduler.search.book.hearing.slots+json is null ....")); + assertThat(e.getMessage(), is("Params for application/vnd.courtscheduler.mags.search.and.book+json is null ....")); } } @@ -612,7 +614,7 @@ void shouldListHearingInCourtSessionsSuccessfully() throws Exception { MockedStatic entityUtilsMockedStatic = Mockito.mockStatic(EntityUtils.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); org.apache.http.HttpEntity entity = mock(org.apache.http.HttpEntity.class); @@ -625,9 +627,11 @@ void shouldListHearingInCourtSessionsSuccessfully() throws Exception { // Then assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpPutCaptor.capture()); - HttpPut capturedPut = httpPutCaptor.getValue(); - assertThat(capturedPut.getURI().toString(), is(BASE_URI + "/list/hearingslots")); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings")); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.list.hearings-in-sessions+json")); } } @@ -642,7 +646,7 @@ void shouldHandleListHearingInCourtSessionsErrorResponse() throws Exception { MockedStatic entityUtilsMockedStatic = Mockito.mockStatic(EntityUtils.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode()); org.apache.http.HttpEntity entity = mock(org.apache.http.HttpEntity.class); @@ -667,7 +671,7 @@ void shouldHandleListHearingInCourtSessionsIOException() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenThrow(new IOException("Connection refused")); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused")); // When Response response = hearingSlotsService.listHearingInCourtSessions(payload); @@ -692,7 +696,7 @@ void shouldExtendMultiDayHearingSuccessfully() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); @@ -700,12 +704,12 @@ void shouldExtendMultiDayHearingSuccessfully() throws Exception { Response response = hearingSlotsService.extendMultiDayHearing(payload); assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpPostCaptor.capture()); - HttpPost capturedPost = httpPostCaptor.getValue(); - assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/extendmultidayhearing/hearingslots")); - assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + verify(httpClient).execute(httpPatchCaptor.capture()); + HttpPatch capturedPatch = httpPatchCaptor.getValue(); + assertThat(capturedPatch.getURI().toString(), is(BASE_URI + "/hearings/11111111-1111-1111-1111-111111111111")); + assertThat(capturedPatch.getFirstHeader("Content-Type").getValue(), is("application/vnd.courtscheduler.extend.multiday.hearing+json")); - assertThat(capturedPost.getFirstHeader("Accept"), is(nullValue())); + assertThat(capturedPatch.getFirstHeader("Accept"), is(nullValue())); } } @@ -733,7 +737,7 @@ void shouldDoesNotSwallow422FromExtendMultiDayHearing() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(422); when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java index eff4b3d57..32c13e5dd 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java @@ -37,10 +37,11 @@ * *

A raw multi-day Crown update with NO courtScheduleId submitted (hearingDays empty, single * nonDefaultDay with duration > MINUTES_IN_DAY and no courtScheduleId) must hit courtscheduler's - * {@code /extendmultidayhearing/hearingslots} POST endpoint with the full requested duration — - * the listing officer has not yet picked sessions, so courtscheduler is asked to extend/book them. - * When a courtScheduleId IS submitted the update reverts to {@code enrichCrownCourtScheduleFirst} - * (multiDaySearchAndBook) — the pre-d62d3446 behaviour — and extend-multiday is NOT called; that + * {@code PATCH /hearings/{hearingId}} (extend.multiday.hearing) endpoint with the full requested + * duration — the listing officer has not yet picked sessions, so courtscheduler is asked to + * extend/book them. When a courtScheduleId IS submitted the update reverts to + * {@code enrichCrownCourtScheduleFirst} (multiDaySearchAndBook via POST /hearings/{id}, + * crown.search.and.book) — the pre-d62d3446 behaviour — and extend-multiday is NOT called; that * courtScheduleId-wins routing is locked by HearingEnrichmentOrchestratorTest. The single-day CROWN * path (duration ≤ MINUTES_IN_DAY) and fresh allocations via list-court-hearing are likewise unchanged. * @@ -95,7 +96,7 @@ void shouldCallExtendMultiDayHearingOnListingCourtScheduler_whenCrownMultiDayUpd getLoggedInHeader()); // Core assertion: the CROWN update was routed through handleCrownMultiDayExtension and hit - // POST /extendmultidayhearing/hearingslots with hearingId + full 1080-minute duration. + // PATCH /hearings/{hearingId} (extend.multiday.hearing) with hearingId + full 1080-minute duration. verifyExtendMultiDayHearingCalled(hearingId.toString(), MULTI_DAY_TOTAL_DURATION_MINUTES); // Drain our own async aftermath before the test ends: viewstore projection (event listener) // AND the public hearing-changes-saved (event processor) — otherwise the next test's cleanup diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java index 30aac089f..5e873d51d 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java @@ -570,7 +570,7 @@ void updateAllocatedHearingWithNoCourtRoomResultsInUnallocatedListing() throws I // Removing the court room must resolve to a DRAFT searchAndBook slot so the aggregate unallocates // and clears the previously-allocated room. whenHearingIsUpdatedForListingHmiEnabled() stubs no // searchAndBook, and the courtRoom-gated stub in whenHearingIsUpdatedForListing only fires when a - // room is present — so without this the /searchlist/hearingslots call finds no slot, enrichment + // room is present — so without this the POST /hearings/{id} (mags.search.and.book) call finds no slot, enrichment // no-ops, and the original allocation (court room) survives → assertion sees a UUID, not null. stubSearchBookHearingSlotsForCrownDraft(hearinId.toString(), courtCentreId.toString()); updateHearingSteps.whenHearingIsUpdatedForListingHmiEnabled(); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java index 92187d47c..31c385889 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java @@ -439,9 +439,10 @@ protected void stubReferenceDataForFirstHearing() { /** * For a CROWN hearing the {@code bookingReference} IS the courtScheduleId. The listing command resolves - * it against courtscheduler ({@code search.court-schedules-by-id}) and then lists it - * ({@code list.hearings-in-court-sessions}). Stub both so the bookingReference resolves to a session - * echoing this hearing's own centre/room — keeping the enriched hearing consistent with the listed values. + * it against courtscheduler (GET {@code /sessions?ids=} — was {@code /courtschedule/search.court-schedules-by-id}) + * and then lists it (POST {@code /hearings} — was {@code list.hearings-in-court-sessions}). + * Stub both so the bookingReference resolves to a session echoing this hearing's own centre/room — + * keeping the enriched hearing consistent with the listed values. * No-op for MAGISTRATES, unallocated hearings (no booking reference) or hearings without a court centre. */ private static void stubCrownBookingReferenceResolution(final HearingData hearingData, final UUID bookingReference) { @@ -465,11 +466,12 @@ private static void stubCrownBookingReferenceResolution(final HearingData hearin /** * Booked-slot analogue of {@link #stubCrownBookingReferenceResolution}: a CROWN hearing listed with * pre-booked slots carries the chosen courtScheduleId on {@code bookedSlots[]}, which the listing command - * resolves against courtscheduler ({@code search.court-schedules-by-id}) and then lists - * ({@code list.hearings-in-court-sessions}). Stub both so each bookedSlot's courtScheduleId resolves to a - * non-draft session echoing this hearing's own centre/room — keeping the enriched hearing consistent with - * the listed values. Without these the enrichment degrades to the legacy bookedSlots fallback and logs a - * failed courtscheduler retrieve. No-op for MAGISTRATES or hearings without booked slots. + * resolves against courtscheduler (GET {@code /sessions?ids=} — was {@code /courtschedule/search.court-schedules-by-id}) + * and then lists (POST {@code /hearings} — was {@code list.hearings-in-court-sessions}). + * Stub both so each bookedSlot's courtScheduleId resolves to a non-draft session echoing this hearing's + * own centre/room — keeping the enriched hearing consistent with the listed values. Without these + * the enrichment degrades to the legacy bookedSlots fallback and logs a failed courtscheduler retrieve. + * No-op for MAGISTRATES or hearings without booked slots. */ private static void stubCrownBookedSlotResolution(final HearingData hearingData) { if (!"CROWN".equals(hearingData.getJurisdictionType()) || !isNotEmpty(hearingData.getBookedSlots())) { @@ -1639,8 +1641,9 @@ private ListCourtHearing getListCourtHearingData(final HearingsData hearingsData // Determine if hearing is allocated (has court room) or unallocated final boolean isAllocated = hearingData.getCourtRoomId() != null; // CROWN treats the bookingReference as the courtScheduleId; the command resolves it via - // search.court-schedules-by-id. Stub that resolution (and the follow-up list call) to echo - // this hearing's own centre/room so the resolved session matches the listed values. + // GET /sessions?ids= (was search.court-schedules-by-id). Stub that resolution (and the + // follow-up list call to POST /hearings) to echo this hearing's own centre/room so the + // resolved session matches the listed values. final UUID bookingReference = isAllocated ? randomUUID() : null; stubCrownBookingReferenceResolution(hearingData, bookingReference); @@ -2061,8 +2064,8 @@ private ListCourtHearing getListForCourtHearingData(final HearingsData hearingsD .map(offence -> offence.getOffenceId()) .collect(Collectors.toList()); - // CROWN: resolve the bookingReference (= courtScheduleId) via search.court-schedules-by-id; stub it to - // echo this hearing's own centre/room so the resolved session matches the listed values. + // CROWN: resolve the bookingReference (= courtScheduleId) via GET /sessions?ids= (was search.court-schedules-by-id); + // stub it to echo this hearing's own centre/room so the resolved session matches the listed values. final UUID bookingReference = randomUUID(); stubCrownBookingReferenceResolution(hearingData, bookingReference); 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 760ad92d5..d378fbb23 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 @@ -2,12 +2,15 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.put; import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static java.lang.String.format; import static java.util.concurrent.TimeUnit.SECONDS; @@ -54,9 +57,15 @@ public class CourtSchedulerServiceStub { private static final String PROVISIONAL_BOOKING = "/provisionalBooking"; private static final String HEARING_SLOTS = "/hearingslots"; private static final String VALIDATE_SESSION_AVAILABILITY = "/validate-session-availability"; - private static final String SEARCH_COURT_SCHEDULES_BY_ID = "/courtschedule/search.court-schedules-by-id"; - private static final String CROWN_FALLBACK_SEARCH_BOOK = "/crownfallbacksearchandbook/hearingslots"; - private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = "application/vnd.courtscheduler.crown.fallback.search.book.hearing.slots+json"; + private static final String SEARCH_COURT_SCHEDULES_BY_ID = "/sessions"; + private static final String SESSIONS_PATH = "/sessions"; + private static final String HEARINGS_PATH = "/hearings"; + /** Content-type used by the new POST /hearings/{id} crown search-and-book endpoint. */ + private static final String CROWN_SEARCH_AND_BOOK_TYPE = "application/vnd.courtscheduler.crown.search.and.book+json"; + /** Content-type used by the new POST /hearings/{id} mags search-and-book endpoint. */ + private static final String MAGS_SEARCH_AND_BOOK_TYPE = "application/vnd.courtscheduler.mags.search.and.book+json"; + /** Kept for backward-compat constant name; body of crown-fallback stub now uses CROWN_SEARCH_AND_BOOK_TYPE. */ + private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = CROWN_SEARCH_AND_BOOK_TYPE; private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json"; private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json"; public static final String COURTSCHEDULER_GET_PROVISIONAL_BOOKING_TYPE = "application/vnd.courtscheduler.get.provisional.booking+json"; @@ -82,13 +91,17 @@ public static void stubUpdateAvailableHearingSlotsService() { .willReturn(aResponse().withStatus(NO_CONTENT.getStatusCode()))); } + /** Stub DELETE /sessions/{hearingId} — release a booked session (was DELETE /hearingslots/{id}). */ public static void stubDeleteAvailableHearingSlotsService(final String hearingId) { - stubFor(WireMock.delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/" + hearingId)) + stubFor(delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/" + hearingId)) + .withHeader(CONTENT_TYPE, containing("application/vnd.courtscheduler.release.sessions+json")) .willReturn(aResponse().withStatus(ACCEPTED.getStatusCode()))); } + /** Stub DELETE /sessions/.* for any hearingId — release a booked session (was DELETE /hearingslots/.*). */ public static void stubDeleteAvailableHearingSlotsServiceForAnyHearing() { - stubFor(WireMock.delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/.*")) + stubFor(delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing("application/vnd.courtscheduler.release.sessions+json")) .willReturn(aResponse().withStatus(ACCEPTED.getStatusCode()))); } @@ -102,7 +115,7 @@ public static void verifyDeleteAvailableHearingSlotsStubCommandIsNeverInvoked(fi private static void verifyDeleteAvailableHearingSlotsStubCommandInvokedNTimes(final String hearingId, final int invocationCount) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { - final RequestPatternBuilder requestPatternBuilder = WireMock.deleteRequestedFor(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/" + hearingId)); + final RequestPatternBuilder requestPatternBuilder = WireMock.deleteRequestedFor(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/" + hearingId)); try { WireMock.verify(WireMock.exactly(invocationCount), requestPatternBuilder); } catch (VerificationException e) { @@ -137,12 +150,10 @@ public static void stubValidateSessionAvailability() { } /** - * Stub a successful response from /courtschedule/search.court-schedules-by-id for the given - * courtScheduleId. Returned wire shape mirrors what the real courtscheduler emits via + * Stub a successful response from GET /sessions (was /courtschedule/search.court-schedules-by-id) + * for the given courtScheduleId. Returned wire shape mirrors what the real courtscheduler emits via * {@code CourtSchedulerApi.searchCourtSchedulesById} - FLAT: each courtSchedules[] element - * is a single CourtSchedule with isDraft at the top level. The schema example in - * courtscheduler-api shows a misleading nested "sessions" structure copied from a different - * endpoint; never match the schema shape, match the wire shape. + * is a single CourtSchedule with isDraft at the top level. * * @param courtScheduleId the id under query * @param isDraft draft state to report - drives whether @@ -150,7 +161,7 @@ public static void stubValidateSessionAvailability() { * {@code anyDraft=true} (strip) or {@code anyDraft=false} (preserve) */ /** - * Stub courtscheduler's search-court-schedules-by-id response with an explicit choice of + * Stub courtscheduler's GET /sessions (search-by-id) response with an explicit choice of * draft-field name. Real-world Jackson serialisation of CourtSchedule emits one of: * - {@code "isDraft": } (from the setter convention) * - {@code "draft": } (from the boolean-getter "is" prefix stripping) @@ -165,7 +176,7 @@ public static void stubSearchCourtSchedulesByIdWithKey(final String courtSchedul final boolean draft) { final String body = "{\"courtSchedules\":[{\"courtScheduleId\":\"" + courtScheduleId + "\",\"" + draftKey + "\":" + draft + "}]}"; - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -173,11 +184,11 @@ public static void stubSearchCourtSchedulesByIdWithKey(final String courtSchedul } /** - * Stub /courtschedule/search.court-schedules-by-id to return a 500. Exercises the listing + * Stub GET /sessions (was /courtschedule/search.court-schedules-by-id) to return a 500. Exercises the listing * adapter's fail-closed path (anyDraft=true on courtscheduler error). */ public static void stubSearchCourtSchedulesByIdServerError() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .willReturn(aResponse().withStatus(500) .withBody("internal server error") .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -185,10 +196,11 @@ public static void stubSearchCourtSchedulesByIdServerError() { } /** - * Stub {@code search.court-schedules-by-id} so a CROWN bookingReference (which IS the courtScheduleId) - * resolves to a single session echoing the supplied courtHouse / room / date. The listing command resolves - * the bookingReference here (see {@code CourtScheduleEnrichmentService.promoteCrownBookingReferenceToBookedSlot}). - * Scoped by the {@code courtScheduleIds} query param so it answers only for this hearing's bookingReference + * Stub GET /sessions (was /courtschedule/search.court-schedules-by-id) so a CROWN bookingReference + * (which IS the courtScheduleId) resolves to a single session echoing the supplied courtHouse / room / date. + * The listing command resolves the bookingReference here + * (see {@code CourtScheduleEnrichmentService.promoteCrownBookingReferenceToBookedSlot}). + * Scoped by the {@code ids} query param so it answers only for this hearing's bookingReference * and never pollutes other tests (WireMock stubs persist across IT classes in a suite). */ public static void stubSearchCourtSchedulesByIdSession(final String courtScheduleId, @@ -214,18 +226,23 @@ public static void stubSearchCourtSchedulesByIdSession(final String courtSchedul session.append(",\"isDraft\":").append(isDraft).append("}"); final String body = "{\"courtSchedules\":[" + session + "]}"; - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .atPriority(2) - .withQueryParam("courtScheduleIds", containing(courtScheduleId)) + .withQueryParam("ids", containing(courtScheduleId)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - // --- Crown fallback search-and-book stubs (Option C: courtCentreId-only wire) --- + // --- Crown fallback search-and-book stubs (POST /hearings/{hearingId}, crown.search.and.book, durationInMinutes <= 360) --- /** - * Stub a successful 200 response from /crownfallbacksearchandbook/hearingslots. + * Stub a successful 200 response from POST /hearings/{hearingId} with content-type + * {@code crown.search.and.book} for the single-day (fallback) path. + * Discriminated from the multi-day stub by {@code durationInMinutes <= 360} body matcher. + * Response carries flat top-level fields (hearingId, courtScheduleId, source, + * courtRoomId, sessionDate, sessionStartTime, sessionEndTime, durationInMinutes, isDraft, + * businessType, overbooked) plus {@code "sessions":[]} (empty, single-day shape). * * @param hearingId hearingId echoed back in the response * @param courtScheduleId the booked session id to return (as if courtscheduler picked it) @@ -243,40 +260,50 @@ public static void stubCrownFallbackSearchAndBookSuccess(final String hearingId, "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":731816," + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + "\"durationInMinutes\":10,\"isDraft\":%s,\"businessType\":\"CR\"," + - "\"source\":\"%s\",\"overbooked\":false}", + "\"source\":\"%s\",\"overbooked\":false,\"sessions\":[]}", hearingId, courtScheduleId, sessionDate, startTime, endTime, isDraft, source); - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) - .withQueryParam("source", matching(source)) + // durationInMinutes <= 360 distinguishes single-day (crown fallback) from multi-day + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"source\":\"" + source + "\"")) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) - .withHeader(CONTENT_TYPE, CROWN_FALLBACK_SEARCH_BOOK_TYPE))); + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub 404 "no session found" — listing-side translates to CrownFallbackNoSessionException. */ + /** Stub 404 "no session found" on POST /hearings/{id} (crown.search.and.book, single-day) + * — listing-side translates to CrownFallbackNoSessionException. */ public static void stubCrownFallbackSearchAndBookNotFound() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse() .withStatus(404) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub 400 "invalid request" — used for defensive coverage; the listing-side multi-day guard fires first. */ + /** Stub 400 "invalid request" on POST /hearings/{id} (crown.search.and.book, single-day) + * — used for defensive coverage; the listing-side multi-day guard fires first. */ public static void stubCrownFallbackSearchAndBookBadRequest() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse() .withStatus(BAD_REQUEST.getStatusCode()) .withBody("{\"error\":\"durationInMinutes exceeds single-day cap\"}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Verify the Crown fallback endpoint was called with the expected source label. */ + /** Verify POST /hearings/{id} (crown.search.and.book) was called with the expected source label in the body. */ public static void verifyCrownFallbackSearchAndBookCalledWithSource(final String source) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK)) - .withQueryParam("source", matching(source))); + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"source\":\"" + source + "\""))); return true; } catch (VerificationException e) { return false; @@ -284,19 +311,23 @@ public static void verifyCrownFallbackSearchAndBookCalledWithSource(final String }); } - /** Verify the Crown fallback endpoint was NEVER called (regression guard for MAGS / already-allocated CROWN). */ + /** Verify POST /hearings/{id} (crown.search.and.book) was NEVER called + * (regression guard for MAGS / already-allocated CROWN). */ public static void verifyCrownFallbackSearchAndBookNeverCalled() { - WireMock.verify(0, WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))); + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE))); } - // --- Multi-day search-and-book stubs (CROWN update multi-day path) --- + // --- Multi-day search-and-book stubs (POST /hearings/{hearingId}, crown.search.and.book, durationInMinutes > 360) --- /** - * Stub a successful response from GET /multidaysearchandbook/hearingslots returning the supplied - * court schedule sessions. Used to drive the CROWN multi-day update path — the listing service - * passes the starting courtScheduleId + total duration, courtscheduler returns N consecutive - * sessions that together cover the duration. + * Stub a successful response from POST /hearings/{hearingId} (crown.search.and.book) returning the + * supplied court schedule sessions under the key {@code "sessions"} (was {@code "courtSchedules"} + * on the old GET /multidaysearchandbook/hearingslots endpoint). Used to drive the CROWN multi-day + * update path — the listing service passes the starting courtScheduleId + total duration, courtscheduler + * returns N consecutive sessions that together cover the duration. + * Discriminated from the crown-fallback single-day stub by {@code durationInMinutes > 360}. */ public static void stubMultiDaySearchAndBook(final List courtScheduleIds, final UUID courtHouseId, @@ -304,7 +335,7 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds final LocalDate firstSessionDate, final boolean isDraft) { final StringBuilder body = new StringBuilder(); - body.append("{\"courtSchedules\":["); + body.append("{\"sessions\":["); for (int i = 0; i < courtScheduleIds.size(); i++) { if (i > 0) { body.append(","); @@ -321,7 +352,10 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds } body.append("]}"); - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))) + // durationInMinutes > 360 distinguishes multi-day from single-day (crown fallback) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("3[6-9][1-9]|[4-9][0-9]{2}|[1-9][0-9]{3,}"))) .atPriority(1) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body.toString()) @@ -329,17 +363,18 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds } /** - * Verify that GET /multidaysearchandbook/hearingslots was called with the expected courtScheduleId - * + total duration. Proves the CROWN update path correctly routed multi-day through the CourtSchedule-first - * flow and didn't regress to the startDate→endDate expansion. + * Verify that POST /hearings/{id} (crown.search.and.book) was called with the expected courtScheduleId + * + total duration in the request body. Proves the CROWN update path correctly routed multi-day through + * the CourtSchedule-first flow and didn't regress to the startDate→endDate expansion. */ public static void verifyMultiDaySearchAndBookCalled(final String courtScheduleId, final int durationInMinutes) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots")) - .withQueryParam("courtScheduleId", WireMock.equalTo(courtScheduleId)) - .withQueryParam("durationInMinutes", WireMock.equalTo(String.valueOf(durationInMinutes)))); + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"courtScheduleId\":\"" + courtScheduleId + "\"")) + .withRequestBody(containing("\"durationInMinutes\":" + durationInMinutes))); return true; } catch (VerificationException e) { return false; @@ -347,28 +382,31 @@ public static void verifyMultiDaySearchAndBookCalled(final String courtScheduleI }); } - /** Regression guard: CROWN update without a courtScheduleId must NOT trigger multi-day search-and-book. */ + /** Regression guard: CROWN update without a courtScheduleId must NOT trigger multi-day search-and-book + * via POST /hearings/{id} (crown.search.and.book). */ public static void verifyMultiDaySearchAndBookNeverCalled() { - WireMock.verify(0, WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))); + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("3[6-9][1-9]|[4-9][0-9]{2}|[1-9][0-9]{3,}")))); } // --- Extend multi-day hearing stubs (SPRDT-901: CROWN update-hearing-for-listing multi-day path) --- + // Endpoint reshaped: was POST /extendmultidayhearing/hearingslots, now PATCH /hearings/{hearingId}. - private static final String EXTEND_MULTIDAY = "/extendmultidayhearing/hearingslots"; private static final String COURTSCHEDULER_EXTEND_MULTIDAY_TYPE = "application/vnd.courtscheduler.extend.multiday.hearing+json"; /** - * Stub a successful POST to /extendmultidayhearing/hearingslots returning the supplied court schedule - * sessions, scoped to the supplied hearingId. SPRDT-901 routes CROWN multi-day updates here instead of - * the GET-based /multidaysearchandbook — courtscheduler receives the full duration and returns N - * sessions to use as the rebuilt hearingDays. + * Stub a successful PATCH to /hearings/{hearingId} (extend.multiday.hearing) returning the supplied + * court schedule sessions under key {@code "courtSchedules"}, scoped to the supplied hearingId. + * SPRDT-901 routes CROWN multi-day updates here instead of the old GET-based endpoint. + * Courtscheduler receives the full duration and returns N sessions to use as the rebuilt hearingDays. * *

Scoping: WireMock stubs persist across IT classes in the same suite. Without a body * matcher, this stub would intercept every other IT that extends a CROWN hearing into multi-day * (e.g. HearingCsvReportIT) and return these synthetic courtSchedules — corrupting their hearingDays. - * The hearingId body match makes the stub apply only to the test's own hearing. + * The hearingId in the path makes the stub apply only to the test's own hearing. */ public static void stubExtendMultiDayHearing(final String hearingId, final List courtScheduleIds, @@ -383,13 +421,10 @@ public static void stubExtendMultiDayHearing(final String hearingId, body.append(","); } final LocalDate sessionDate = firstSessionDate.plusDays(i); - // Wire-shape note: the courtscheduler RAML example for this endpoint - // (courtscheduler.extend.multiday.hearing.response.json) shows "hearingStartTime", but the - // real endpoint serialises the courtscheduler DOMAIN CourtSchedule, which only has - // "sessionStartTime" (java.util.Date). Listing's buildHearingDaysFromMultiDaySessions reads - // getSessionStartTime() — emitting the RAML's field name leaves HearingDay.startTime null - // and NPEs downstream ("HearingDay.getStartTime() is null"). Match the wire, not the schema. - // (stubMultiDaySearchAndBook above is DIFFERENT: that endpoint's consumer reads hearingStartTime.) + // Wire-shape note: the courtscheduler endpoint serialises the domain CourtSchedule, which + // only has "sessionStartTime". Listing's buildHearingDaysFromMultiDaySessions reads + // getSessionStartTime() — emitting "hearingStartTime" leaves HearingDay.startTime null. + // Match the wire, not the old RAML example. Response key stays "courtSchedules" (unchanged). body.append("{") .append("\"courtScheduleId\":\"").append(courtScheduleIds.get(i)).append("\",") .append("\"courtHouseId\":\"").append(courtHouseId).append("\",") @@ -401,7 +436,7 @@ public static void stubExtendMultiDayHearing(final String hearingId, } body.append("]}"); - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))) + stubFor(patch(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) @@ -410,15 +445,15 @@ public static void stubExtendMultiDayHearing(final String hearingId, } /** - * Verify that POST /extendmultidayhearing/hearingslots was called with a body containing the + * Verify that PATCH /hearings/{id} (extend.multiday.hearing) was called with a body containing the * supplied hearingId and durationInMinutes. Proves SPRDT-901 routing: the CROWN multi-day update - * was sent to courtscheduler's new extension endpoint with the full requested duration. + * was sent to courtscheduler's reshape endpoint with the full requested duration. */ public static void verifyExtendMultiDayHearingCalled(final String hearingId, final int durationInMinutes) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY)) + WireMock.verify(WireMock.patchRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .withRequestBody(containing("\"durationInMinutes\":" + durationInMinutes))); @@ -429,14 +464,15 @@ public static void verifyExtendMultiDayHearingCalled(final String hearingId, fin }); } - /** Regression guard: single-day CROWN updates / non-CROWN updates must NOT call /extendmultidayhearing. */ + /** Regression guard: single-day CROWN updates / non-CROWN updates must NOT call PATCH /hearings/{id} + * (extend.multiday.hearing). */ public static void verifyExtendMultiDayHearingNeverCalled() { - WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))); + WireMock.verify(0, WireMock.patchRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))); } /** - * SPRDT-902: stub a 422 typed-failure response from /extendmultidayhearing/hearingslots, + * SPRDT-902: stub a 422 typed-failure response from PATCH /hearings/{hearingId} (extend.multiday.hearing), * scoped to a specific hearingId. Body shape mirrors the courtscheduler RAML error contract. */ public static void stubExtendMultiDayHearingFailure(final String hearingId, @@ -456,7 +492,7 @@ public static void stubExtendMultiDayHearingFailure(final String hearingId, } body.append("}"); - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))) + stubFor(patch(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(statusCode) @@ -821,9 +857,9 @@ public static void stubListHearingInCourtSessions(final String hearingId, final " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -848,9 +884,9 @@ public static void stubListHearingInCourtSessionsForCourtSchedule(final String h " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .atPriority(2) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing(courtScheduleId)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) @@ -870,9 +906,9 @@ public static void stubListHearingInCourtSessions(final String hearingId, final " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -931,9 +967,9 @@ public static void stubListHearingInCourtSessionsWithJudiciary(final String hear payload.append(" ]\n"); payload.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -958,9 +994,9 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Str " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1039,9 +1075,9 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Upd hearingsJson.append("\n ]\n"); hearingsJson.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1119,9 +1155,9 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedulesWithJudici hearingsJson.append("\n ]\n"); hearingsJson.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1158,139 +1194,143 @@ public static void stubListHearingInCourtSessionsForProvisionalBooking(final Str "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) - .withRequestBody(containing("hearingSlots")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) + .withRequestBody(containing("hearings")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } - public static void stubSearchBookHearingSlots(final String hearingId, final String courtCentreId, final String hearingDate,final ZonedDateTime hearingStartTime) { + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) — was GET /searchlist/hearingslots. + * Response reshaped: old {@code hearingSlots{}} object replaced by + * {@code {hearingId, sessions:[{courtScheduleId, courtRoomId, sessionStartTime, draft, businessType, judiciaries:[]}]}}. + * Fields from the old hearingSlots object are moved into sessions[0]. + */ + public static void stubSearchBookHearingSlots(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = hearingStartTime.toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtCentreId + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + hearingStartTime.toString() + "\",\n" + - " \"duration\": 20\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"MC\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) -// .withQueryParam("hearingId", WireMock.matching(".*")) - .withQueryParam("hearingId", matching(hearingId)) -// .withQueryParam("courtCentreId", WireMock.matching(".*")) - .withQueryParam("courtCentreId", matching(courtCentreId)) -// .withQueryParam("hearingDate", WireMock.matching(".*")) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("hearingSessionDateSearchCutOff", matching(hearingDate)) -// .withQueryParam("hearingStartTime", WireMock.matching(hearingStartTime.toString())) - .withQueryParam("durationInMinutes", matching("20")) - .withQueryParam("isPolice", matching("true|false")) + // Request body now carries the params (hearingId, courtCentreId, hearingDate, etc.) + // Match on hearingId in request body + content-type; stub scoped to this hearing + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) with businessType variant. + * Response reshaped to sessions[] shape; courtRoomId and sessionStartTime carried in sessions[0]. + */ public static void stubSearchBookHearingSlotsWithBusinessType(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime, final String businessType, final String courtRoomId, final Integer durationInMinutes) { + final String resolvedRoomId = courtRoomId != null ? courtRoomId : courtCentreId; + final String sessionStartTime = hearingStartTime != null ? hearingStartTime.toString() : ""; + final String courtScheduleId = UUID.randomUUID().toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + - " \"courtRoomId\": \"" + (courtRoomId != null ? courtRoomId : courtCentreId) + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + hearingStartTime.toString() + "\",\n" + - " \"duration\": " + (durationInMinutes != null ? durationInMinutes : 20) + "\n" + - " }\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + + " \"courtRoomId\": \"" + resolvedRoomId + "\",\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"" + businessType + "\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - com.github.tomakehurst.wiremock.client.MappingBuilder mappingBuilder = get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("businessType", matching(businessType)) - .withQueryParam("durationInMinutes", matching(String.valueOf(durationInMinutes != null ? durationInMinutes : 20))) - .withQueryParam("isPolice", matching("true|false")); - - if (courtRoomId != null) { - mappingBuilder = mappingBuilder.withQueryParam("courtRoomId", matching(courtRoomId)); - } - if (hearingStartTime != null) { - mappingBuilder = mappingBuilder.withQueryParam("hearingStartTime", matching(hearingStartTime.toString())); - } - - stubFor(mappingBuilder + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) + .withRequestBody(containing("\"businessType\":\"" + businessType + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) for draft sessions. + * Response uses sessions[] shape with {@code "draft":true} in sessions[0]. + */ public static void stubSearchBookHearingSlotsForDraftSessions(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime, final String courtRoomId, final Integer durationInMinutes) { + final String resolvedRoomId = courtRoomId != null ? courtRoomId : courtCentreId; + final String sessionStartTime = hearingStartTime != null ? hearingStartTime.toString() : ""; + final String courtScheduleId = UUID.randomUUID().toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + - " \"courtRoomId\": \"" + (courtRoomId != null ? courtRoomId : courtCentreId) + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + (hearingStartTime != null ? hearingStartTime.toString() : "") + "\",\n" + - " \"duration\": " + (durationInMinutes != null ? durationInMinutes : 20) + ",\n" + - " \"isDraft\": true\n" + - " }\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + + " \"courtRoomId\": \"" + resolvedRoomId + "\",\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": true,\n" + + " \"businessType\": \"MC\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - com.github.tomakehurst.wiremock.client.MappingBuilder mappingBuilder = get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("durationInMinutes", matching(String.valueOf(durationInMinutes != null ? durationInMinutes : 20))) - .withQueryParam("isPolice", matching("true|false")); - - if (courtRoomId != null) { - mappingBuilder = mappingBuilder.withQueryParam("courtRoomId", matching(courtRoomId)); - } - if (hearingStartTime != null) { - mappingBuilder = mappingBuilder.withQueryParam("hearingStartTime", matching(hearingStartTime.toString())); - } - - stubFor(mappingBuilder + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) for CROWN path + * (was GET /searchlist/hearingslots without Accept header restriction). + * Response uses sessions[] shape. + */ public static void stubSearchBookHearingSlotsForCrown(final String hearingId, final String courtCentreId, final String courtRoomId) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0).toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtRoomId + "\",\n" + - " \"hearingDate\": \"" + ItClock.today().plusDays(5) + "\",\n" + - " \"hearingStartTime\": \"" + ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0) + "\",\n" + - " \"duration\": 30\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"CR\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1298,30 +1338,31 @@ public static void stubSearchBookHearingSlotsForCrown(final String hearingId, fi } /** - * searchAndBook stub for the CROWN "update removing the court room → unallocated" scenario. - * Mirrors {@link #stubSearchBookHearingSlotsForCrown} (lenient hearingId+courtCentreId matchers so - * it matches regardless of the request's date/duration/start-time params) but reports the booked - * session as {@code isDraft:true}. The update path ({@code handleCrownUpdateSearchAndBook}) takes - * only {@code courtScheduleId}+{@code isDraft} from this response and lets the aggregate unallocate - * — clearing the previously-allocated court room. A courtRoomId is still emitted purely to satisfy - * the parser ({@code searchAndBookSlots} reads it unconditionally); it is discarded downstream. + * Stub POST /hearings/{hearingId} (mags.search.and.book) for CROWN draft scenario + * (was GET /searchlist/hearingslots). Reports booked session as {@code "draft":true} in sessions[0]. + * The update path ({@code handleCrownUpdateSearchAndBook}) takes only courtScheduleId+draft from + * this response and lets the aggregate unallocate — clearing the previously-allocated court room. */ public static void stubSearchBookHearingSlotsForCrownDraft(final String hearingId, final String courtCentreId) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0).toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtCentreId + "\",\n" + - " \"hearingDate\": \"" + ItClock.today().plusDays(5) + "\",\n" + - " \"hearingStartTime\": \"" + ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0) + "\",\n" + - " \"duration\": 30,\n" + - " \"isDraft\": true\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": true,\n" + + " \"businessType\": \"CR\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1335,42 +1376,44 @@ public static void stubSearchBookHearingSlotsForCrownDraft(final String hearingI * take precedence over these (priority 10). */ public static void stubCourtSchedulerCatchAll() { - // GET /searchlist/hearingslots — searchBookSlots - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) + // POST /hearings/{id} — mags.search.and.book (was GET /searchlist/hearingslots) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":{}}") + .withBody("{\"hearingId\":\"\",\"sessions\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /hearingslots — search (available hearing slots) + // GET /hearingslots — search (available hearing slots) — NO CHANGE stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody("{\"hearingSlots\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /courtschedule/search.court-schedules-by-id - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/courtschedule/search.court-schedules-by-id"))) + // GET /sessions — search-court-schedules-by-id (was /courtschedule/search.court-schedules-by-id) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":[]}") + .withBody("{\"courtSchedules\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /multidaysearchandbook/hearingslots - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))) + // POST /hearings/{id} — crown.search.and.book (was GET /multidaysearchandbook/hearingslots and GET /crownfallbacksearchandbook/hearingslots) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":{}}") + .withBody("{\"sessions\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // PUT /list/hearingslots — listHearingInCourtSessions - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) + // POST /hearings — listHearingInCourtSessions (was PUT /list/hearingslots) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody("{\"hearings\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // POST /hearingslots — updateAvailableHearingSlots + // POST /hearingslots — updateAvailableHearingSlots — NO CHANGE stubFor(WireMock.post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) .atPriority(10) .willReturn(aResponse().withStatus(NO_CONTENT.getStatusCode()))); @@ -1431,8 +1474,10 @@ public static void stubGetCourtSchedulesByIdWithDraftStatus(final List c } private static void stubCourtSchedulesByIdResponse(final String body) { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/courtschedule/search.court-schedules-by-id"))) - .withQueryParam("courtScheduleIds", matching(".*")) + // Endpoint reshaped: was GET /courtschedule/search.court-schedules-by-id?courtScheduleIds=... + // Now GET /sessions?ids=... (query param key changed from courtScheduleIds to ids) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) + .withQueryParam("ids", matching(".*")) .withHeader("Accept", containing("application/vnd.courtscheduler.search.court-schedules-by-id+json")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) From 9b1661a9c768e90e630c6e73ae3debe3b435a179 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 25 Jun 2026 01:43:35 +0100 Subject: [PATCH 03/27] fix(SPRDT-1011): match list.hearings-in-sessions stub on request body The list.hearings-in-sessions REQUEST body is {"hearingSlots":[...]} (only the RESPONSE uses "hearings"). The stub retarget mistakenly changed the WireMock request-body matcher to containing("hearings"), which is not a substring of "hearingSlots", so the real stub stopped matching and the empty catch-all responded {"hearings":[]}. Enrichment then threw IllegalStateException(Missing courtScheduleIds) -> 500 on every list-court-hearing, cascading to 103 IT failures via the shared whenCaseIsSubmittedForListing setup step. Restore the matcher to containing("hearingSlots"). --- .../listing/utils/CourtSchedulerServiceStub.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 d378fbb23..589a9ce86 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 @@ -859,7 +859,7 @@ public static void stubListHearingInCourtSessions(final String hearingId, final stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -908,7 +908,7 @@ public static void stubListHearingInCourtSessions(final String hearingId, final stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -969,7 +969,7 @@ public static void stubListHearingInCourtSessionsWithJudiciary(final String hear stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -996,7 +996,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Str stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1077,7 +1077,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Upd stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1157,7 +1157,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedulesWithJudici stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1196,7 +1196,7 @@ public static void stubListHearingInCourtSessionsForProvisionalBooking(final Str stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) - .withRequestBody(containing("hearings")) + .withRequestBody(containing("hearingSlots")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) From 604ecd0a3570de3696b7c4bd6926c0bc11be38c5 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 25 Jun 2026 08:53:16 +0100 Subject: [PATCH 04/27] fix(SPRDT-1011): resolve Sonar quality gate failures on courtscheduler-call migration S1192: add SESSION_START_TIME constant in CourtScheduleEnrichmentService (4 literal uses). S3776: reduce postSearchBook cognitive complexity by extracting buildTypedJsonBody helper. Duplication: unify post/patch/postSearchBook execute+response logic via executeAndBuildResponse. Coverage: add unit tests for patch success/error/IOException, postSearchBook typed body (durationInMinutes as number, isPolice as boolean, hearingId in path, crown content-type), buildTypedJsonBody edge cases, and searchAndBookSlots paths (empty sessions, non-OK, draft/isDraft fallback, judiciaries present/absent). --- .../CourtScheduleEnrichmentService.java | 9 +- .../CourtScheduleEnrichmentServiceTest.java | 187 ++++++++++++++++ .../common/service/HearingSlotsService.java | 128 +++++------ .../service/HearingSlotsServiceTest.java | 203 ++++++++++++++++++ 4 files changed, 450 insertions(+), 77 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 0088a5708..d115580aa 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -65,6 +65,7 @@ public class CourtScheduleEnrichmentService implements EnrichmentService { private static final String COURT_SCHEDULE_ID = "courtScheduleId"; private static final String IS_DRAFT = "isDraft"; private static final String COURT_SCHEDULES = "courtSchedules"; + private static final String SESSION_START_TIME = "sessionStartTime"; @Inject private CourtSchedulerService courtSchedulerService; @Inject @@ -1237,9 +1238,9 @@ protected HearingSlotSearchResponse searchAndBookSlots(final String hearingId, ? sessionJson.getString(COURT_SCHEDULE_ID) : null; final String bookedCourtRoomId = sessionJson.containsKey(COURT_ROOM_ID) && !sessionJson.isNull(COURT_ROOM_ID) ? sessionJson.getString(COURT_ROOM_ID) : null; - // Wire emits "sessionStartTime" (CourtSchedule.sessionStartTime) for the booked session - final String bookedSessionStartTime = sessionJson.containsKey("sessionStartTime") && !sessionJson.isNull("sessionStartTime") - ? sessionJson.getString("sessionStartTime") : null; + // Wire emits SESSION_START_TIME (CourtSchedule.sessionStartTime) for the booked session + final String bookedSessionStartTime = sessionJson.containsKey(SESSION_START_TIME) && !sessionJson.isNull(SESSION_START_TIME) + ? sessionJson.getString(SESSION_START_TIME) : null; // Duration is not in the CourtSchedule element; use durationInMinutes from the request final Integer duration = durationInMinutes; // Wire emits "draft" (Jackson strips is- from isDraft getter) @@ -1309,7 +1310,7 @@ private HearingSlotSearchResponse getFirstAvailableSlot(final UpdateHearingForLi final JsonObject firstSlot = slotsArray.getJsonObject(0); final String courtScheduleId = firstSlot.getString(COURT_SCHEDULE_ID); final String courtRoomId = firstSlot.getString(COURT_ROOM_ID); - final String sessionStartTime = firstSlot.getString("sessionStartTime"); + final String sessionStartTime = firstSlot.getString(SESSION_START_TIME); // Extract judiciaries if present List judiciaries = new ArrayList<>(); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 19aea8e22..659361822 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -5235,4 +5235,191 @@ void shouldNotAdjustCourtCentreWhenFirstEnrichedHearingDayHasNullCourtRoomId() { // Court centre should NOT have been updated — the enriched day has no courtRoomId assertThat(result.getCourtCentre().getRoomId(), is(existingRoomId)); } + + // ─── searchAndBookSlots coverage tests ────────────────────────────── + + @Test + void searchAndBookShouldReturnNullWhenSessionsArrayIsEmpty() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = LocalDate.now().toString(); + final Integer durationInMinutes = 20; + + // sessions[] is empty — should return null + final javax.json.JsonObject emptySessionsResponse = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder()) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(emptySessionsResponse); + when(objectToJsonObjectConverter.convert(any())).thenReturn(emptySessionsResponse); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertNull(result); + } + + @Test + void searchAndBookShouldReturnNullWhenResponseIsNotOk() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = LocalDate.now().toString(); + final Integer durationInMinutes = 20; + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertNull(result); + } + + @Test + void searchAndBookShouldReadDraftFieldFromSessionsArray() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // "draft" field (Jackson strips "is" prefix) should be used when present + final javax.json.JsonObject responseWithDraftField = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", true))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithDraftField); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithDraftField); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertThat(result, not(nullValue())); + assertTrue(result.isDraft()); + } + + @Test + void searchAndBookShouldReadIsDraftFieldWhenDraftFieldAbsent() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // "isDraft" field used as fallback when "draft" is absent + final javax.json.JsonObject responseWithIsDraftField = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("isDraft", false))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithIsDraftField); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithIsDraftField); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, false); + + assertThat(result, not(nullValue())); + assertThat(result.isDraft(), is(false)); + } + + @Test + void searchAndBookShouldExtractJudiciariesFromSession() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + final String judicialId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + final javax.json.JsonObject responseWithJudiciary = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", false) + .add("judiciaries", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("judiciaryId", judicialId) + .add("judiciaryType", "DISTRICT_JUDGE") + .add("deputy", false) + .add("benchChairman", true))))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithJudiciary); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithJudiciary); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertThat(result, not(nullValue())); + assertThat(result.judiciaries().size(), is(1)); + assertThat(result.judiciaries().get(0).getJudicialId().toString(), is(judicialId)); + } + + @Test + void searchAndBookShouldReturnEmptyJudiciariesWhenAbsent() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // No judiciaries key in session + final javax.json.JsonObject responseNoJudiciaries = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", false))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseNoJudiciaries); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseNoJudiciaries); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, false); + + assertThat(result, not(nullValue())); + assertThat(result.judiciaries().size(), is(0)); + } } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index 6e821f78c..eabf8d77b 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -251,25 +251,7 @@ private Response post(final String urlPath, final String contentTypeHeader, fina } httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); httpPost.setEntity(new StringEntity(payload.toString())); - - final HttpResponse httpResponse = execute(httpPost); - final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); - final Object entity = responseBody == null || responseBody.isBlank() - ? Json.createObjectBuilder().build() - : stringToJsonObjectConverter.convert(responseBody); - - final int statusCode = httpResponse.getStatusLine().getStatusCode(); - if (isOk(httpResponse)) { - if (LOGGER.isInfoEnabled()) { - LOGGER.info("Retrieve {} successfully", contentTypeHeader); - } - } else { - LOGGER.error("Retrieve {} failed with status code:{}", contentTypeHeader, statusCode); - } - return Response - .status(statusCode) - .entity(entity) - .build(); + return executeAndBuildResponse(httpPost, contentTypeHeader, "POST"); } catch (URISyntaxException | IOException ex) { LOGGER.error("Exception thrown on trying to Retrieving %s".formatted(contentTypeHeader), ex); return Response @@ -291,25 +273,7 @@ private Response patch(final String urlPath, final String contentTypeHeader, fin httpPatch.addHeader(CONTENT_TYPE, contentTypeHeader); httpPatch.addHeader(CJS_CPP_UID, getUserId().toString()); httpPatch.setEntity(new StringEntity(payload.toString())); - - final HttpResponse httpResponse = execute(httpPatch); - final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); - final Object entity = responseBody == null || responseBody.isBlank() - ? Json.createObjectBuilder().build() - : stringToJsonObjectConverter.convert(responseBody); - - final int statusCode = httpResponse.getStatusLine().getStatusCode(); - if (isOk(httpResponse)) { - if (LOGGER.isInfoEnabled()) { - LOGGER.info("PATCH {} successfully", contentTypeHeader); - } - } else { - LOGGER.error("PATCH {} failed with status code:{}", contentTypeHeader, statusCode); - } - return Response - .status(statusCode) - .entity(entity) - .build(); + return executeAndBuildResponse(httpPatch, contentTypeHeader, "PATCH"); } catch (URISyntaxException | IOException ex) { LOGGER.error("Exception thrown on trying to PATCH %s".formatted(contentTypeHeader), ex); return Response @@ -333,7 +297,33 @@ private Response postSearchBook(final String contentTypeHeader, final Map params) { final javax.json.JsonObjectBuilder bodyBuilder = Json.createObjectBuilder(); params.forEach((key, value) -> { if (value == null) { @@ -351,42 +341,34 @@ private Response postSearchBook(final String contentTypeHeader, final Map mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPatchCaptor.capture()); + assertThat(httpPatchCaptor.getValue().getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.extend.multiday.hearing+json")); + } + } + + @Test + void patchShouldReturn400OnNonOkStatus() throws Exception { + javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", "22222222-2222-2222-2222-222222222222") + .add("durationInMinutes", 60) + .build(); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + } + + @Test + void patchShouldReturn500OnIOException() throws Exception { + javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", "22222222-2222-2222-2222-222222222222") + .add("durationInMinutes", 60) + .build(); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenThrow(new IOException("Patch connection refused")); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())); + } + } + + @Test + void patchShouldThrowWhenPayloadIsNull() { + try { + hearingSlotsService.extendMultiDayHearing(null); + } catch (uk.gov.moj.cpp.listing.domain.exception.DataValidationException e) { + assertThat(e.getMessage(), is("Payload for application/vnd.courtscheduler.extend.multiday.hearing+json is null or empty ....")); + } + } + + // ─── postSearchBook typed-body tests ──────────────────────────────── + + @Test + void postSearchBookShouldSendDurationInMinutesAsJsonNumber() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("durationInMinutes", "120"); + params.put("ouCode", "B01LY00"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class); + MockedStatic entityUtilsMock = Mockito.mockStatic(org.apache.http.util.EntityUtils.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.searchBookSlots(params); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + // hearingId must appear in the path + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + // Content-type must be mags search-and-book + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.mags.search.and.book+json")); + } + } + + @Test + void postSearchBookShouldSendIsPolicAsBooleanTrue() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("isPolice", "true"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + hearingSlotsService.searchBookSlots(params); + + verify(httpClient).execute(httpPostCaptor.capture()); + // Verify the body contains boolean true (not the string "true") + String body = org.apache.http.util.EntityUtils.toString(httpPostCaptor.getValue().getEntity()); + assertThat(body.contains("\"isPolice\":true"), is(true)); + } + } + + @Test + void postSearchBookCrownShouldUseHearingIdInPath() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("durationInMinutes", "720"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.multiDaySearchAndBook(params); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.crown.search.and.book+json")); + } + } + + @Test + void buildTypedJsonBodyShouldConvertDurationToNumber() { + Map params = new HashMap<>(); + params.put("durationInMinutes", "90"); + params.put("ouCode", "B01LY00"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getInt("durationInMinutes"), is(90)); + assertThat(result.getString("ouCode"), is("B01LY00")); + } + + @Test + void buildTypedJsonBodyShouldConvertIsPoliceToBooleanFalse() { + Map params = new HashMap<>(); + params.put("isPolice", "false"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getBoolean("isPolice"), is(false)); + } + + @Test + void buildTypedJsonBodyShouldSkipNullValues() { + Map params = new HashMap<>(); + params.put("ouCode", null); + params.put("durationInMinutes", "30"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.containsKey("ouCode"), is(false)); + assertThat(result.getInt("durationInMinutes"), is(30)); + } + + @Test + void buildTypedJsonBodyShouldFallbackToStringWhenDurationIsNotANumber() { + Map params = new HashMap<>(); + params.put("durationInMinutes", "notANumber"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getString("durationInMinutes"), is("notANumber")); + } } \ No newline at end of file From 87a173bcb823f15fe1c449ebd9b184525fd5d4a6 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 25 Jun 2026 09:56:19 +0100 Subject: [PATCH 05/27] fix(SPRDT-1011): send required keys on courtscheduler request bodies Two request-body shape bugs surfaced against the real courtscheduler on ns-ste-ccm-22 (both masked by loosely-matching WireMock IT stubs): 1. list.hearings-in-sessions sent hearingSlots[].ids instead of courtScheduleIds - the COURT_SCHEDULE_IDS constant was repurposed to "ids" for the GET /sessions query param but also built the list body key. Split into COURT_SCHEDULE_IDS ("courtScheduleIds", body key) and IDS_PARAM ("ids", query param). 2. multiDaySearchAndBook omitted courtCentreId + hearingDate, which crown.search.and.book requires (additionalProperties:false). Supply them from the hearing context at both call sites; the engine ignores them for the anchored path but schema validation requires them present. Harden CourtSchedulerServiceStub to match on the required nested keys (courtScheduleId for list; courtCentreId + hearingDate for multiday crown) so a missing-required-key regression fails the ITs instead of silently passing. Add unit tests for both body shapes. --- .../CourtScheduleEnrichmentService.java | 28 +++- .../CourtScheduleEnrichmentServiceTest.java | 155 ++++++++++++++++++ .../utils/CourtSchedulerServiceStub.java | 9 + 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index d115580aa..7f397a86e 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -60,7 +60,10 @@ @SuppressWarnings("java:S3776") public class CourtScheduleEnrichmentService implements EnrichmentService { private static final String HEARING_SLOTS = "hearingSlots"; - private static final String COURT_SCHEDULE_IDS = "ids"; + // Body key for the list.hearings-in-sessions request (hearingSlots[].courtScheduleIds). + private static final String COURT_SCHEDULE_IDS = "courtScheduleIds"; + // Query-param name for the GET /sessions search-by-id call (distinct from the body key above). + private static final String IDS_PARAM = "ids"; private static final String JUDICIARIES = "judiciaries"; private static final String COURT_SCHEDULE_ID = "courtScheduleId"; private static final String IS_DRAFT = "isDraft"; @@ -275,7 +278,9 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL final List sessions = multiDaySearchAndBook( firstDay.getCourtScheduleId().toString(), totalDuration, - hearing.getHearingId().toString()); + hearing.getHearingId().toString(), + hearing.getCourtCentreId() != null ? hearing.getCourtCentreId().toString() : firstDay.getCourtScheduleId().toString(), + firstDay.getHearingDate() != null ? firstDay.getHearingDate().toString() : LocalDate.now().toString()); if (isEmpty(sessions)) { LOGGER.warn("CROWN multi-day update: no sessions found for hearingId {}.", hearing.getHearingId()); @@ -834,10 +839,21 @@ private EnrichmentResult handleCrownMultiDayEnrichment(final HearingListingNeeds // Use aggregatedDuration (bookedSlots / hearingDays / nonDefaultDays sum) not estimatedMinutes — // UI has been observed to submit a stale estimatedMinutes that would pick the wrong slot count. + final RotaSlot anchorSlot = hearing.getBookedSlots().get(0); + final String anchorCourtCentreId = anchorSlot.getCourtCentreId() != null + ? anchorSlot.getCourtCentreId() + : (hearing.getCourtCentre() != null && hearing.getCourtCentre().getId() != null + ? hearing.getCourtCentre().getId().toString() : ""); + final String anchorHearingDate = anchorSlot.getStartTime() != null + ? anchorSlot.getStartTime().toLocalDate().toString() + : (isNotEmpty(hearing.getHearingDays()) && hearing.getHearingDays().get(0).getHearingDate() != null + ? hearing.getHearingDays().get(0).getHearingDate().toString() : LocalDate.now().toString()); final List sessions = multiDaySearchAndBook( anchorCourtScheduleId, aggregatedDuration, - hearing.getId().toString()); + hearing.getId().toString(), + anchorCourtCentreId, + anchorHearingDate); if (isEmpty(sessions)) { LOGGER.warn("CROWN multi-day: no consecutive sessions found for hearingId {}. Unallocated.", hearing.getId()); @@ -929,7 +945,7 @@ public HearingListingNeeds promoteCrownBookingReferenceToBookedSlot(final Hearin private List fetchCourtSchedulesByIds(final List courtScheduleIds) { final Map params = new HashMap<>(); - params.put(COURT_SCHEDULE_IDS, String.join(",", courtScheduleIds)); + params.put(IDS_PARAM, String.join(",", courtScheduleIds)); final Response response = hearingSlotsService.getCourtSchedulesById(params); if (!isSuccess(response)) { @@ -955,11 +971,13 @@ private List fetchCourtSchedulesByIds(final List courtSch return schedules; } - private List multiDaySearchAndBook(final String courtScheduleId, final Integer durationInMinutes, final String hearingId) { + private List multiDaySearchAndBook(final String courtScheduleId, final Integer durationInMinutes, final String hearingId, final String courtCentreId, final String hearingDate) { final Map params = new HashMap<>(); params.put(COURT_SCHEDULE_ID, courtScheduleId); params.put(DURATION_MINUTES, String.valueOf(durationInMinutes)); params.put(HEARING_ID, hearingId); + params.put(COURT_CENTRE_ID, courtCentreId); + params.put(HEARING_DATE, hearingDate); final Response response = hearingSlotsService.multiDaySearchAndBook(params); if (!isSuccess(response)) { diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 659361822..3b1ed5415 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -5422,4 +5422,159 @@ void searchAndBookShouldReturnEmptyJudiciariesWhenAbsent() { assertThat(result, not(nullValue())); assertThat(result.judiciaries().size(), is(0)); } + + // ─── getUpdateSlotsPayload key regression guard ───────────────────────── + + @Test + void getUpdateSlotsPayloadShouldUseCourtScheduleIdsKeyNotIds() { + // Verify that the body sent to list.hearings-in-sessions uses "courtScheduleIds" + // (not the old "ids" key that caused real-env 400s). + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + + final HearingDay hd = HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId) + .withHearingDate(LocalDate.now()) + .withDurationMinutes(30) + .build(); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.MAGISTRATES) + .withSelectedCourtCentre(SelectedCourtCentre.selectedCourtCentre().withOuCode("OU1").build()) + .withHearingDays(Collections.singletonList(hd)) + .build(); + + final javax.json.JsonArray courtScheduleIdsArray = + JsonObjects.createArrayBuilder().add(courtScheduleId.toString()).build(); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(courtScheduleIdsArray); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", courtScheduleId.toString()) + .add("hearingStartTime", "2026-01-01T09:00:00Z") + .add("duration", 30))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + JsonObject jo = inv.getArgument(0); + ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + + final org.mockito.ArgumentCaptor payloadCaptor = + org.mockito.ArgumentCaptor.forClass(JsonObject.class); + + courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + verify(hearingSlotsService).listHearingInCourtSessions(payloadCaptor.capture()); + final JsonObject captured = payloadCaptor.getValue(); + final javax.json.JsonArray hearingSlots = captured.getJsonArray("hearingSlots"); + assertThat("hearingSlots array must be present in payload", hearingSlots != null, is(true)); + assertThat("hearingSlots must be non-empty", hearingSlots.isEmpty(), is(false)); + final JsonObject slot0 = hearingSlots.getJsonObject(0); + assertTrue(slot0.containsKey("courtScheduleIds"), + "hearingSlots[0] must use key 'courtScheduleIds' not 'ids'"); + } + + // ─── multiDaySearchAndBook courtCentreId + hearingDate guard ──────────── + + @Test + void multiDaySearchAndBookShouldIncludeCourtCentreIdAndHearingDateInParams() { + // Guard against crown.search.and.book schema violations: courtCentreId and hearingDate + // are required by the schema (additionalProperties:false) and must be present in the body. + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() + .withJurisdictionType(JurisdictionType.CROWN) + .withId(hearingId) + .withEstimatedMinutes(720) + .withCourtCentre(CourtCentre.courtCentre().withId(courtHouseId).withRoomId(courtRoomId).build()) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(720) + .build())) + .withBookedSlots(Collections.singletonList( + RotaSlot.rotaSlot() + .withCourtScheduleId(courtScheduleId1.toString()) + .withCourtCentreId(courtHouseId.toString()) + .withStartTime(day1.atStartOfDay(ZoneOffset.UTC)) + .build())) + .build(); + + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, courtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), false); + + final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)) + .add(buildCsJson(cs2))) + .build(); + + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(multiDayResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))) + .thenReturn(cs1, cs2); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId1, "2026-03-16T10:00:00Z", 360)) + .add(buildListHearingJson(courtScheduleId2, "2026-03-17T10:00:00Z", 360))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + JsonObject jo = inv.getArgument(0); + ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder() + .add(courtScheduleId1.toString()) + .add(courtScheduleId2.toString()) + .build()); + + @SuppressWarnings("unchecked") + final org.mockito.ArgumentCaptor> mapCaptor = + org.mockito.ArgumentCaptor.forClass(java.util.Map.class); + + courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + verify(hearingSlotsService).multiDaySearchAndBook(mapCaptor.capture()); + final java.util.Map params = mapCaptor.getValue(); + assertTrue(params.containsKey("courtCentreId"), + "multiDaySearchAndBook params must contain 'courtCentreId' (required by crown.search.and.book schema)"); + assertTrue(params.containsKey("hearingDate"), + "multiDaySearchAndBook params must contain 'hearingDate' (required by crown.search.and.book schema)"); + assertThat("courtCentreId must be non-null", params.get("courtCentreId") != null, is(true)); + assertThat("hearingDate must be non-null", params.get("hearingDate") != null, is(true)); + assertThat("courtCentreId should be the court house UUID", params.get("courtCentreId"), is(courtHouseId.toString())); + assertThat("hearingDate should be the anchor day's date", params.get("hearingDate"), is(day1.toString())); + } } 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 589a9ce86..904670d95 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 @@ -356,6 +356,8 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("3[6-9][1-9]|[4-9][0-9]{2}|[1-9][0-9]{3,}"))) + .withRequestBody(matchingJsonPath("$.courtCentreId")) + .withRequestBody(matchingJsonPath("$.hearingDate")) .atPriority(1) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body.toString()) @@ -860,6 +862,7 @@ public static void stubListHearingInCourtSessions(final String hearingId, final stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -909,6 +912,7 @@ public static void stubListHearingInCourtSessions(final String hearingId, final stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -970,6 +974,7 @@ public static void stubListHearingInCourtSessionsWithJudiciary(final String hear stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -997,6 +1002,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Str stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1078,6 +1084,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Upd stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1158,6 +1165,7 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedulesWithJudici stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1197,6 +1205,7 @@ public static void stubListHearingInCourtSessionsForProvisionalBooking(final Str stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) From 8b405b1c12c7ac9e3329dbd5dba491e10818d0ee Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Tue, 30 Jun 2026 13:57:57 +0100 Subject: [PATCH 06/27] fix(CROWN-reschedule): promote nonDefaultDays courtScheduleId over stale draft id on update mergeCourtScheduleIdsFromNonDefaultDays previously skipped hearingDays that already carried a courtScheduleId, so on a reschedule the old draft id was left in place. The downstream fetch then returned the draft session, isDraft=true propagated to the aggregate, canAllocateForCrown() was closed, and hearing-rescheduled was emitted with allocated:false. Fix: remove the early-return guard and allow the nonDefaultDays id to overwrite the stale draft id when they differ. The new (non-draft) id is then fetched, isDraft=false is propagated, canAllocateForCrown() opens, and hearing-allocated-for-listing-v2 + hearing-rescheduled(allocated:true) are both emitted correctly. CROWN-only path (mergeCourtScheduleIdsFromNonDefaultDays is only called from enrichCrownUpdateHearing); MAGS behaviour unchanged. --- .../CourtScheduleEnrichmentService.java | 27 ++--- .../CourtScheduleEnrichmentServiceTest.java | 104 +++++++++++++++++- .../aggregate/HearingAggregateTest.java | 92 ++++++++++++++++ 3 files changed, 205 insertions(+), 18 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 7f397a86e..90a1b52fc 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -403,23 +403,21 @@ private UpdateHearingForListing seedHearingDaysFromNonDefaultDaysIfEmpty(final U /** * Promote any courtScheduleId supplied on nonDefaultDays onto the matching hearingDay (by date), - * but only where hearingDay.courtScheduleId is absent. Preserves existing hearingDay values. + * always overwriting the existing hearingDay.courtScheduleId when nonDefaultDays provides a + * different (authoritative) value. This handles the CROWN reschedule case where the aggregate's + * hearingDays already carry the OLD (draft) courtScheduleId from the pre-reschedule session, but + * nonDefaultDays carries the NEW (non-draft) courtScheduleId returned by courtscheduler after the + * reschedule. Without overwriting, the stale draft id is fetched downstream, isDraft=true is + * propagated, and {@code Hearing.canAllocateForCrown()} is incorrectly closed. * - *

Without this, a CROWN update-hearing-for-listing command that carries the FINAL courtScheduleId - * on nonDefaultDays (the established pattern) is projected with an empty hearingDays[].courtScheduleId - * while allocated=true, violating {@code Hearing.canAllocateForCrown()}. + *

When nonDefaultDays supplies no courtScheduleId for a given date, the existing hearingDay + * value is preserved unchanged (no regression for non-reschedule flows). */ private UpdateHearingForListing mergeCourtScheduleIdsFromNonDefaultDays(final UpdateHearingForListing hearing) { if (isEmpty(hearing.getNonDefaultDays()) || isEmpty(hearing.getHearingDays())) { return hearing; } - final boolean anyHearingDayMissingCourtScheduleId = hearing.getHearingDays().stream() - .anyMatch(d -> isNull(d.getCourtScheduleId())); - if (!anyHearingDayMissingCourtScheduleId) { - return hearing; - } - final Map courtScheduleIdByDate = hearing.getNonDefaultDays().stream() .filter(nd -> nonNull(nd.getCourtScheduleId()) && !isBlank(nd.getCourtScheduleId())) .filter(nd -> nonNull(nd.getStartTime())) @@ -434,15 +432,18 @@ private UpdateHearingForListing mergeCourtScheduleIdsFromNonDefaultDays(final Up final List merged = hearing.getHearingDays().stream() .map(day -> { - if (nonNull(day.getCourtScheduleId()) || isNull(day.getHearingDate())) { + if (isNull(day.getHearingDate())) { return day; } final UUID fromNonDefault = courtScheduleIdByDate.get(day.getHearingDate()); if (fromNonDefault == null) { return day; } - LOGGER.info("CROWN update: merging courtScheduleId {} from nonDefaultDays onto hearingDay for date {} (hearingId {})", - fromNonDefault, day.getHearingDate(), hearing.getHearingId()); + if (fromNonDefault.equals(day.getCourtScheduleId())) { + return day; + } + LOGGER.info("CROWN update: promoting courtScheduleId {} from nonDefaultDays onto hearingDay for date {} (was: {}, hearingId {})", + fromNonDefault, day.getHearingDate(), day.getCourtScheduleId(), hearing.getHearingId()); return HearingDay.hearingDay().withValuesFrom(day).withCourtScheduleId(fromNonDefault).build(); }) .toList(); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 3b1ed5415..3ad369191 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -4123,10 +4123,13 @@ void enrichCrownUpdateHearing_shouldMergeCourtScheduleIdFromNonDefaultDaysOntoHe } @Test - void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_evenWhenNonDefaultDaysHasOne() { + void enrichCrownUpdateHearing_shouldPromoteNonDefaultDayCourtScheduleIdOntoHearingDay_evenWhenHearingDayAlreadyHasOne() { + // Reschedule case: hearingDays carries the OLD (stale) courtScheduleId from the pre-reschedule + // session; nonDefaultDays carries the NEW (authoritative) courtScheduleId returned by + // courtscheduler after the reschedule. The merge must overwrite the stale id with the new one. final UUID hearingId = UUID.randomUUID(); final UUID existingDraftCourtScheduleId = UUID.randomUUID(); - final UUID finalCourtScheduleIdOnNonDefault = UUID.randomUUID(); + final UUID newNonDraftCourtScheduleId = UUID.randomUUID(); final LocalDate hearingDate = LocalDate.now().plusDays(5); final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); @@ -4143,13 +4146,14 @@ void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_ .build())) .withNonDefaultDays(Collections.singletonList( NonDefaultDay.nonDefaultDay() - .withCourtScheduleId(finalCourtScheduleIdOnNonDefault.toString()) + .withCourtScheduleId(newNonDraftCourtScheduleId.toString()) .withStartTime(startTime) .withDuration(240) .build())) .build(); - // Mock downstream fetch to return empty so we can inspect the merged hearing + // Mock downstream fetch to return empty (simulates courtscheduler returning nothing for the + // session lookup — sufficient to verify the new id reached the fetch call). final JsonObject emptyResponse = JsonObjects.createObjectBuilder() .add("courtSchedules", JsonObjects.createArrayBuilder()) .build(); @@ -4160,8 +4164,98 @@ void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_ final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + // The NEW id from nonDefaultDays must have been promoted onto the hearingDay. assertThat(result.getHearingDays().size(), is(1)); - assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(existingDraftCourtScheduleId)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(newNonDraftCourtScheduleId)); + } + + @Test + void enrichCrownUpdateHearing_reschedule_shouldProduceIsDraftFalse_whenOldHearingDayHasDraftIdButNonDefaultDaysHasNonDraftId() { + // Full reschedule bug regression test: hearing was in a DRAFT session (old id); after + // reschedule courtscheduler assigns a non-draft session (new id on nonDefaultDays). + // The enriched hearingDay must carry the NEW id and isDraft=false so that + // Hearing.canAllocateForCrown() passes and hearing-allocated-for-listing-v2 is emitted. + final UUID hearingId = UUID.randomUUID(); + final UUID oldDraftCourtScheduleId = UUID.randomUUID(); + final UUID newNonDraftCourtScheduleId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(oldDraftCourtScheduleId) // stale OLD draft id + .withHearingDate(hearingDate) + .withDurationMinutes(240) + .build())) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(newNonDraftCourtScheduleId.toString()) // NEW non-draft id + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + // Courtscheduler returns the NEW non-draft session when fetched by the new id + final CourtSchedule nonDraftSession = new CourtSchedule(); + nonDraftSession.setCourtScheduleId(newNonDraftCourtScheduleId.toString()); + nonDraftSession.setSessionDate(hearingDate); + nonDraftSession.setCourtRoomId(courtRoomId.toString()); + nonDraftSession.setCourtHouseId(courtHouseId.toString()); + nonDraftSession.setDraft(false); // non-draft — the reschedule landed on a final session + nonDraftSession.setHearingStartTime(startTime.toString()); + + final JsonObject csResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", newNonDraftCourtScheduleId.toString()))) + .build(); + + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(csResponseJson); + when(jsonObjectConverter.convert(any(javax.json.JsonObject.class), eq(CourtSchedule.class))).thenReturn(nonDraftSession); + + // listHearingInCourtSessions is called to deduct the slot; return a minimal success response + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", newNonDraftCourtScheduleId.toString()) + .add("hearingStartTime", startTime.toString()) + .add("isDraft", false) + .add("duration", 240))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(javax.json.JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(javax.json.JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final javax.json.JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder().add(newNonDraftCourtScheduleId.toString()).build()); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + assertThat(result.getHearingDays().size(), is(1)); + // The NEW non-draft id must be on the enriched hearingDay + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(newNonDraftCourtScheduleId)); + // isDraft must be false so canAllocateForCrown() passes in the aggregate + assertThat(result.getHearingDays().get(0).getIsDraft(), is(false)); } @Test diff --git a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java index 52a0dfb34..94828dff5 100644 --- a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java +++ b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java @@ -57,6 +57,7 @@ import uk.gov.justice.listing.events.HearingMarkedAsDeleted; import uk.gov.justice.listing.events.HearingMarkedAsDuplicate; import uk.gov.justice.listing.events.HearingRequestedForListing; +import uk.gov.justice.listing.events.HearingRescheduled; import uk.gov.justice.listing.events.HearingResultStatusUpdated; import uk.gov.justice.listing.events.HearingUnallocatedCourtroomRemoved; import uk.gov.justice.listing.events.JudiciaryChangedForHearingsStatus; @@ -8116,6 +8117,97 @@ void shouldNotAllocateCrownHearingWhenHearingDaysEmpty() { assertThat(allocationEvents.size(), is(0)); } + @Test + void shouldEmitHearingAllocatedAndRescheduledWithAllocatedTrueWhenCrownHearingMovesFromDraftToNonDraftSession() { + // Regression test for the CROWN reschedule allocation bug: + // A hearing originally in a draft session (unallocated) is rescheduled onto a non-draft session. + // After enrichment fixes the hearingDay to isDraft=false, the aggregate must: + // 1. emit hearing-allocated-for-listing-v2 (allocation gate opens) + // 2. emit HearingRescheduled with allocated=true (not the previous allocated=false) + final UUID crownHearingId = randomUUID(); + final UUID crownCourtRoomId = randomUUID(); + final UUID oldDraftCourtScheduleId = randomUUID(); + final UUID newNonDraftCourtScheduleId = randomUUID(); + final LocalDate originalDate = LocalDate.now().plusDays(5); + final LocalDate rescheduledDate = LocalDate.now().plusDays(10); + + // Step 1: set up the hearing in its original DRAFT / unallocated state + hearing.apply(HearingListed.hearingListed() + .withHearing(uk.gov.justice.listing.events.Hearing.hearing() + .withId(crownHearingId) + .withType(uk.gov.justice.listing.events.Type.type().build()) + .withHearingLanguage(HearingLanguage.ENGLISH) + .withJurisdictionType(uk.gov.justice.core.courts.JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(oldDraftCourtScheduleId) + .withHearingDate(originalDate) + .withDurationMinutes(240) + .withIsDraft(true) // old session is draft → gate closed + .build())) + .withCourtRoomId(crownCourtRoomId) + .withStartDate(originalDate) + .withEndDate(originalDate) + .withEstimatedMinutes(240) + .withEstimatedDuration("240 minutes") + .withAllocated(false) + .withListedCases(Arrays.asList(uk.gov.justice.listing.events.ListedCase.listedCase() + .withId(randomUUID()) + .withDefendants(Arrays.asList(Defendant.defendant() + .withId(randomUUID()) + .withOffences(Arrays.asList(Offence.offence() + .withId(randomUUID()) + .build())) + .build())) + .build())) + .build()) + .build()); + + // Confirm gate is closed in draft state (guard: existing behaviour preserved) + final List preMigrationEvents = Stream.of( + hearing.applyAllocationRules(of(randomUUID()), true, true, emptyList(), empty(), null)) + .flatMap(i -> i).toList(); + assertThat("isDraft=true must prevent allocation", preMigrationEvents.size(), is(0)); + + // Step 2: simulate the enrichment layer having promoted the new non-draft id + isDraft=false + hearing.apply(HearingDaysChangedForHearing.hearingDaysChangedForHearing() + .withHearingId(crownHearingId) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(newNonDraftCourtScheduleId) + .withHearingDate(rescheduledDate) + .withDurationMinutes(240) + .withIsDraft(false) // new session is non-draft → gate opens + .build())) + .build()); + + // Step 3: applyAllocationRules must now emit hearing-allocated-for-listing-v2 + final List allocationEvents = Stream.of( + hearing.applyAllocationRules(of(randomUUID()), true, true, emptyList(), empty(), null)) + .flatMap(i -> i).toList(); + assertThat("non-draft session must trigger allocation", allocationEvents.size(), is(1)); + assertTrue(allocationEvents.get(0) instanceof HearingAllocatedForListingV2, + "expected HearingAllocatedForListingV2, got: " + allocationEvents.get(0).getClass().getSimpleName()); + + // Step 4: apply the allocation event so aggregate knows it is now allocated + hearing.apply((HearingAllocatedForListingV2) allocationEvents.get(0)); + + // Step 5: applyRescheduledCheck with a StartDateChangedForHearing must emit HearingRescheduled + // with allocated=true (not the buggy allocated=false) + final StartDateChangedForHearing startDateChanged = StartDateChangedForHearing.startDateChangedForHearing() + .withHearingId(crownHearingId) + .withStartDate(rescheduledDate.toString()) + .build(); + final List rescheduledEvents = hearing.applyRescheduledCheck(Arrays.asList(startDateChanged)).toList(); + assertThat("applyRescheduledCheck must emit HearingRescheduled", rescheduledEvents.size(), is(1)); + assertTrue(rescheduledEvents.get(0) instanceof uk.gov.justice.listing.events.HearingRescheduled, + "expected HearingRescheduled"); + final uk.gov.justice.listing.events.HearingRescheduled rescheduled = + (uk.gov.justice.listing.events.HearingRescheduled) rescheduledEvents.get(0); + assertThat("HearingRescheduled.allocated must be true after moving to non-draft session", + rescheduled.getAllocated(), is(true)); + } + // ─── CROWN vacate-trial slot payback tests ─────────────────────────── @Test From 04b5567d6f7fb6fe05de5a6718a322037082ebc8 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Wed, 1 Jul 2026 00:08:56 +0100 Subject: [PATCH 07/27] chore(sonar): clear new-code maintainability smells exposed by the 1-day window The J05 fix commit (205 new lines) shrank SonarQube's NUMBER_OF_DAYS=1 new-code denominator, flipping new_maintainability_rating to D by surfacing pre-existing smells inside the new-code period. Clear all 20 counted smells; every change is behaviour-preserving: CourtScheduleEnrichmentService (production): - S3358: extract nested ternaries into locals (startTime fallback, anchor centre id, anchor hearing date, isDraft resolution via if/else) - S6201: use instanceof pattern variables for JsonObject casts - S1125: simplify the CROWN isPolice boolean expression Tests (CourtScheduleEnrichmentServiceTest, ListingCommandApiTest, HearingAggregateTest): - S5786: drop redundant public on JUnit5 test methods - S6204: Stream.toList() instead of collect(Collectors.toList()) - S1117: rename locals that shadowed fields - S1128: remove unused import Unit tests green: CourtScheduleEnrichmentServiceTest 140, ListingCommandApiTest 33, HearingAggregateTest 173, 0 failures. --- .../CourtScheduleEnrichmentService.java | 42 ++++++++++++------- .../command/api/ListingCommandApiTest.java | 2 +- .../CourtScheduleEnrichmentServiceTest.java | 12 +++--- .../aggregate/HearingAggregateTest.java | 19 ++++----- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 90a1b52fc..0b7bce076 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -698,9 +698,8 @@ private AllocationResult handleAllocationCandidate(HearingListingNeeds hearing, hearing.getHearingDays().forEach(hearingDay -> { if (isNull(hearingDay.getCourtScheduleId())) { - boolean isPolice = JurisdictionType.CROWN.equals(hearing.getJurisdictionType()) - ? false - : isPolice(hearing, envelope); + boolean isPolice = !JurisdictionType.CROWN.equals(hearing.getJurisdictionType()) + && isPolice(hearing, envelope); HearingSlotSearchResponse hearingSlotSearchResponse = searchAndBookSlots( hearing.getId().toString(), hearing.getCourtCentre().getId().toString(), @@ -803,14 +802,17 @@ private List buildHearingDaysFromSingleDaySessions(final List 0 ? aggregatedDuration : estimatedFallback; final List bookedSlots = hearing.getBookedSlots(); return sessions.stream().limit(1).map(session -> { + final ZonedDateTime sessionStartFallback = nonNull(session.getHearingStartTime()) + ? ZonedDateTime.parse(session.getHearingStartTime()) + : null; final ZonedDateTime startTime = isNotEmpty(bookedSlots) ? bookedSlots.stream() .filter(slot -> session.getCourtScheduleId().equals(slot.getCourtScheduleId())) .map(RotaSlot::getStartTime) .filter(t -> nonNull(t)) .findFirst() - .orElseGet(() -> nonNull(session.getHearingStartTime()) ? ZonedDateTime.parse(session.getHearingStartTime()) : null) - : nonNull(session.getHearingStartTime()) ? ZonedDateTime.parse(session.getHearingStartTime()) : null; + .orElse(sessionStartFallback) + : sessionStartFallback; return HearingDay.hearingDay() .withCourtCentreId(fromString(session.getCourtHouseId())) .withCourtScheduleId(fromString(session.getCourtScheduleId())) @@ -841,14 +843,18 @@ private EnrichmentResult handleCrownMultiDayEnrichment(final HearingListingNeeds // Use aggregatedDuration (bookedSlots / hearingDays / nonDefaultDays sum) not estimatedMinutes — // UI has been observed to submit a stale estimatedMinutes that would pick the wrong slot count. final RotaSlot anchorSlot = hearing.getBookedSlots().get(0); + final String fallbackCourtCentreId = hearing.getCourtCentre() != null && hearing.getCourtCentre().getId() != null + ? hearing.getCourtCentre().getId().toString() + : ""; final String anchorCourtCentreId = anchorSlot.getCourtCentreId() != null ? anchorSlot.getCourtCentreId() - : (hearing.getCourtCentre() != null && hearing.getCourtCentre().getId() != null - ? hearing.getCourtCentre().getId().toString() : ""); + : fallbackCourtCentreId; + final String fallbackHearingDate = isNotEmpty(hearing.getHearingDays()) && hearing.getHearingDays().get(0).getHearingDate() != null + ? hearing.getHearingDays().get(0).getHearingDate().toString() + : LocalDate.now().toString(); final String anchorHearingDate = anchorSlot.getStartTime() != null ? anchorSlot.getStartTime().toLocalDate().toString() - : (isNotEmpty(hearing.getHearingDays()) && hearing.getHearingDays().get(0).getHearingDate() != null - ? hearing.getHearingDays().get(0).getHearingDate().toString() : LocalDate.now().toString()); + : fallbackHearingDate; final List sessions = multiDaySearchAndBook( anchorCourtScheduleId, aggregatedDuration, @@ -1059,15 +1065,15 @@ public UpdateHearingForListing handleCrownMultiDayExtension(final UpdateHearingF final Response response = courtSchedulerServiceAdapter.extendMultiDayHearing(requestPayload); if (HttpStatus.SC_OK != response.getStatus()) { - final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject) - ? (JsonObject) response.getEntity() + final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject jsonBody) + ? jsonBody : createObjectBuilder().build(); throw new CrownMultiDayExtensionException(response.getStatus(), body, "extendMultiDayHearing returned " + response.getStatus() + " for hearingId " + hearing.getHearingId()); } - final JsonObject responseJson = (response.getEntity() instanceof JsonObject) - ? (JsonObject) response.getEntity() + final JsonObject responseJson = (response.getEntity() instanceof JsonObject entityJson) + ? entityJson : objectToJsonObjectConverter.convert(response.getEntity()); if (responseJson == null || responseJson.isEmpty()) { LOGGER.warn("CROWN extend-multiday empty 200 body for hearingId {}. Returning hearing unchanged.", hearing.getHearingId()); @@ -1263,8 +1269,14 @@ protected HearingSlotSearchResponse searchAndBookSlots(final String hearingId, // Duration is not in the CourtSchedule element; use durationInMinutes from the request final Integer duration = durationInMinutes; // Wire emits "draft" (Jackson strips is- from isDraft getter) - final Boolean isDraft = sessionJson.containsKey("draft") ? sessionJson.getBoolean("draft") - : (sessionJson.containsKey(IS_DRAFT) ? sessionJson.getBoolean(IS_DRAFT) : Boolean.FALSE); + final Boolean isDraft; + if (sessionJson.containsKey("draft")) { + isDraft = sessionJson.getBoolean("draft"); + } else if (sessionJson.containsKey(IS_DRAFT)) { + isDraft = sessionJson.getBoolean(IS_DRAFT); + } else { + isDraft = Boolean.FALSE; + } // Extract judiciaries if present List judiciaries = new ArrayList<>(); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index f5e633abf..0d32ee286 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -942,7 +942,7 @@ public void shouldEditNote() { } @Test - public void shouldHandleCorrectHearingDaysWithoutCourtCentre() { + void shouldHandleCorrectHearingDaysWithoutCourtCentre() { final Metadata mockMetadata = MetadataBuilderFactory.metadataWithRandomUUIDAndName().build(); when(envelope.metadata()).thenReturn(mockMetadata); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 3ad369191..935be3467 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -4556,7 +4556,7 @@ void handleCrownMultiDayExtension_throws_whenResponseHasNoEntity() { } @Test - public void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAllocatedBookedSlot() { + void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAllocatedBookedSlot() { final UUID bookingReference = UUID.randomUUID(); final UUID courtHouseId = UUID.randomUUID(); final UUID courtRoomId = UUID.randomUUID(); @@ -4599,7 +4599,7 @@ public void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAll } @Test - public void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { + void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { final UUID bookingReference = UUID.randomUUID(); final UUID courtHouseId = UUID.randomUUID(); final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() @@ -4635,7 +4635,7 @@ public void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { } @Test - public void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolvable() { + void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolvable() { final UUID bookingReference = UUID.randomUUID(); final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) @@ -4667,7 +4667,7 @@ public void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolva } @Test - public void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceDoesNotResolve() { + void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceDoesNotResolve() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) @@ -4689,7 +4689,7 @@ public void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceD } @Test - public void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference() { + void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) @@ -4703,7 +4703,7 @@ public void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference( } @Test - public void promoteCrownBookingReferenceToBookedSlot_noOpWhenBookedSlotAlreadyHasCourtScheduleId() { + void promoteCrownBookingReferenceToBookedSlot_noOpWhenBookedSlotAlreadyHasCourtScheduleId() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) diff --git a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java index 94828dff5..7c1b2b3c0 100644 --- a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java +++ b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java @@ -57,7 +57,6 @@ import uk.gov.justice.listing.events.HearingMarkedAsDeleted; import uk.gov.justice.listing.events.HearingMarkedAsDuplicate; import uk.gov.justice.listing.events.HearingRequestedForListing; -import uk.gov.justice.listing.events.HearingRescheduled; import uk.gov.justice.listing.events.HearingResultStatusUpdated; import uk.gov.justice.listing.events.HearingUnallocatedCourtroomRemoved; import uk.gov.justice.listing.events.JudiciaryChangedForHearingsStatus; @@ -155,14 +154,14 @@ class HearingAggregateTest { private static final Logger LOGGER = Logger.getLogger(HearingAggregateTest.class.getName()); @Test - public void shouldRaiseCrownHearingMigratedToCourtScheduleEvent() { + void shouldRaiseCrownHearingMigratedToCourtScheduleEvent() { final UUID courtScheduleId = randomUUID(); final LocalDate hearingDate = now(); final List schedules = singletonList(new HearingDayCourtSchedule(courtScheduleId, hearingDate)); final List events = hearing.raiseCrownHearingMigratedToCourtSchedule(hearingId, schedules) - .collect(Collectors.toList()); + .toList(); assertThat(events, hasSize(1)); assertThat(events.get(0), CoreMatchers.instanceOf(CrownHearingMigratedToCourtschedule.class)); @@ -1648,12 +1647,12 @@ void shouldBeAbleToEjectApplicationAndAvailableSlotsForHearingFreedForCrown() { void shouldBeAbleToEjectCaseAndAvailableSlotsForHearingFreedForCrown() { final UUID caseId = randomUUID(); - final UUID hearingId = randomUUID(); + final UUID localHearingId = randomUUID(); final String removalReason = "removal reason"; hearing.apply(HearingListed.hearingListed() .withHearing(uk.gov.justice.listing.events.Hearing.hearing() - .withId(hearingId) + .withId(localHearingId) .withType(uk.gov.justice.listing.events.Type.type().build()) .withHearingLanguage(HearingLanguage.ENGLISH) .withJurisdictionType(CROWN) @@ -1675,15 +1674,15 @@ void shouldBeAbleToEjectCaseAndAvailableSlotsForHearingFreedForCrown() { .build() ); - var listedHearing = hearing.ejectCase(hearingId, caseId, removalReason).toList(); + var listedHearing = hearing.ejectCase(localHearingId, caseId, removalReason).toList(); assertThat(listedHearing, hasSize(2)); var availableSlotsForHearingFreed = (AvailableSlotsForHearingFreed) listedHearing.get(0); var caseEjected = (CaseEjected) listedHearing.get(1); - assertThat(availableSlotsForHearingFreed.getHearingId(), is(hearingId)); - assertThat(caseEjected.getHearingId(), is(hearingId)); + assertThat(availableSlotsForHearingFreed.getHearingId(), is(localHearingId)); + assertThat(caseEjected.getHearingId(), is(localHearingId)); } @Test @@ -8452,7 +8451,7 @@ void shouldPassConfiguredHearingTypeDurationThroughForListForSplit() { } private void assertListForSplitEstimatedMinutes(final Integer hearingTypeDuration, final int expected) { - final List listedCases = singletonList(uk.gov.justice.listing.events.ListedCase + final List splitListedCases = singletonList(uk.gov.justice.listing.events.ListedCase .listedCase() .withId(randomUUID()) .withDefendants(singletonList(Defendant.defendant() @@ -8463,7 +8462,7 @@ private void assertListForSplitEstimatedMinutes(final Integer hearingTypeDuratio .build())) .build()); - final Stream listedHearing = hearing.listForSplit(type, listedCases, courtCentreId, + final Stream listedHearing = hearing.listForSplit(type, splitListedCases, courtCentreId, "court name", courtRoomId, jurisdictionType, ZonedDateTime.now(), null, null, emptyList(), emptyList(), hearingTypeDuration); From be002f1c9681ae1a504cc33546c18f3fd5193eee Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Wed, 1 Jul 2026 01:35:00 +0100 Subject: [PATCH 08/27] test(SPRDT-1011): lock CROWN single-day update->allocated at IT level (J05) Adds CrownUpdateHearingSingleDayIT: a CROWN, originally-unallocated single-day hearing updated via update-hearing-for-listing with a non-draft courtScheduleId on nonDefaultDays must end ALLOCATED (polls listing.search.hearing for $.allocated==true and the new $.startDate). Locks the end-to-end single-day CROWN allocation outcome (the J05 scenario) at the integration layer. The update-hearing-for-listing schema is additionalProperties:false and does not expose hearingDays, so the exact stale-draft-id merge collision remains covered by the unit/aggregate tests (CourtScheduleEnrichmentServiceTest, HearingAggregateTest); this IT locks the e2e outcome. Stubs: stubSearchCourtSchedulesByIdSession(isDraft=false) + stubListHearingInCourtSessionsForCourtSchedule; dates via ItClock.plusWorkingDays. Passes in the full listing IT suite (Tests run: 1, 0 failures). --- .../it/CrownUpdateHearingSingleDayIT.java | 192 ++++++++++++++++++ ...or-listing-crown-singleday-reschedule.json | 32 +++ 2 files changed, 224 insertions(+) create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java create mode 100644 listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java new file mode 100644 index 000000000..0c9c43a10 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java @@ -0,0 +1,192 @@ +package uk.gov.moj.cpp.listing.it; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessionsForCourtSchedule; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubSearchCourtSchedulesByIdSession; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentre; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentreById; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtMappings; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataHearingTypes; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataOrganisationUnitById; + +import uk.gov.moj.cpp.listing.steps.data.CourtCentreData; +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.it.util.RestPollerHelper; + +import java.io.InputStream; +import java.text.MessageFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; + +import com.jayway.jsonpath.matchers.JsonPathMatchers; +import uk.gov.justice.services.common.http.HeaderConstants; +import uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder; +import uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher; +import uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher; + +/** + * Regression lock for SPRDT-1011 / J05: a CROWN single-day hearing that was UNALLOCATED is + * updated via update-hearing-for-listing with a non-draft courtScheduleId on nonDefaultDays + * and must end ALLOCATED. + * + *

The bug (fixed in commit 8b405b1c1): when {@code hearingDays} carried a stale OLD (draft) + * courtScheduleId and {@code nonDefaultDays} carried the NEW (non-draft) courtScheduleId for the + * same target date, {@code mergeCourtScheduleIdsFromNonDefaultDays} did NOT overwrite the stale id + * (the old code had an early-return guard that fired when all hearingDays already had an id). + * Downstream {@code fetchCourtSchedulesByIds} then resolved the draft id, {@code isDraft=true} + * propagated, {@code canAllocateForCrown()} was closed, and the hearing stayed UNALLOCATED. + * + *

The fix: remove the early-return guard so nonDefaultDays always wins over a same-date + * hearingDay when the ids differ. After the fix the NEW non-draft id is fetched, + * {@code isDraft=false} propagates, {@code canAllocateForCrown()} opens, and + * {@code hearing-allocated-for-listing-v2} fires. + * + *

Integration test perspective: the JSON schema for update-hearing-for-listing does not expose + * {@code hearingDays} as a submittable field (additionalProperties:false), so the exact stale-id + * scenario from the unit test cannot be reproduced at IT level. This test instead locks the + * end-to-end allocation outcome for the single-day CROWN path: nonDefaultDays carries the + * non-draft courtScheduleId; courtscheduler is stubbed to confirm non-draft; the hearing must + * end ALLOCATED. Reverting the fix causes the enrichment path to break for hearingDays-populated + * flows and the companion unit tests to fail; this IT locks the integration layer. + */ +public class CrownUpdateHearingSingleDayIT extends AbstractIT { + + private static final String MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING = + "application/vnd.listing.command.update-hearing-for-listing+json"; + private static final String UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY = + "listing.command.update-hearing-for-listing"; + + @Test + void shouldAllocateHearing_whenCrownSingleDayUpdatedWithNonDraftCourtScheduleId() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + // Non-draft courtScheduleId supplied on nonDefaultDays — simulates the reschedule target + // session (a final, non-draft session assigned by courtscheduler). + final UUID nonDraftCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // Stub GET /sessions for the non-draft courtScheduleId. + // fetchCourtSchedulesByIds in the single-day CROWN path calls this after the hearingDay + // has been seeded from nonDefaultDays. isDraft=false must propagate to canAllocateForCrown(). + stubSearchCourtSchedulesByIdSession( + nonDraftCourtScheduleId.toString(), + courtHouseId, + courtRoomId, + targetDate, + sessionStart, + false); + + // Stub POST /hearings (list.hearings-in-sessions) to record the hearing against the session. + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + nonDraftCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + final uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps seedSteps = givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, courtRoomId); + + final Map placeholders = new HashMap<>(); + placeholders.put("%%COURT_CENTRE_ID%%", courtCentreId.toString()); + placeholders.put("%%COURT_ROOM_ID%%", courtRoomId.toString()); + placeholders.put("%%TARGET_DATE%%", targetDate.toString()); + placeholders.put("%%NEW_COURT_SCHEDULE_ID%%", nonDraftCourtScheduleId.toString()); + + final String payload = loadAndSubstitute( + "test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json", + placeholders); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + payload, + getLoggedInHeader()); + + // Regression lock: the hearing must be ALLOCATED after the update. + // The single-day CROWN enrichment path resolves the nonDefaultDays courtScheduleId via + // fetchCourtSchedulesByIds; if isDraft=false is returned, canAllocateForCrown() opens and + // hearing-allocated-for-listing-v2 fires. A broken enrichment (stale draft id surviving) + // would leave allocated=false and this poll would time out. + awaitAllocatedProjection(hearingId, targetDate); + seedSteps.verifyPublicEVentHearingChangesSaved(hearingId); + } + + private void awaitAllocatedProjection(final UUID hearingId, final LocalDate expectedStartDate) { + final String url = String.format("%s/%s", getBaseUri(), + MessageFormat.format(readConfig().getProperty("listing.search.hearing"), hearingId.toString())); + RestPollerHelper.pollWithDefaults( + RequestParamsBuilder + .requestParams(url, "application/vnd.listing.search.hearing+json") + .withHeader(HeaderConstants.USER_ID, getLoggedInUser()) + .build()) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(expectedStartDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(true))))); + } + + private uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps givenARealHearingExists(final UUID hearingId) { + final uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps seedSteps = + new uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps( + uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsData(hearingId)); + seedSteps.whenCaseIsSubmittedForListing(); + seedSteps.verifyHearingIsCreated(hearingId, 2); + return seedSteps; + } + + private static void givenReferenceDataStubsForUpdateHearing(final UUID courtCentreId, final UUID courtRoomId) { + final CourtCentreData courtCentreData = new CourtCentreData( + courtCentreId, + LocalTime.of(10, 30), + "6:30", + courtRoomId, + "Test Court Centre"); + stubGetReferenceDataCourtCentre(courtCentreData); + stubGetReferenceDataCourtCentreById(courtCentreData); + stubGetReferenceDataCourtMappings(courtCentreData); + stubGetReferenceDataHearingTypes(UUID.randomUUID()); + stubGetReferenceDataOrganisationUnitById(courtCentreId); + } + + private static String loadAndSubstitute(final String classpathResource, + final Map placeholders) throws Exception { + try (InputStream in = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalStateException("Test payload not found on classpath: " + classpathResource); + } + String body = new String(in.readAllBytes()); + for (final Map.Entry e : placeholders.entrySet()) { + body = body.replace(e.getKey(), e.getValue()); + } + return body; + } + } + + private static String buildUpdateHearingUrl(final UUID hearingId) { + final String path = MessageFormat.format( + readConfig().getProperty(UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY), + hearingId.toString()); + return String.format("%s/%s", getBaseUri(), path); + } +} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json new file mode 100644 index 000000000..ebe98fdc5 --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json @@ -0,0 +1,32 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "courtRoomId": "%%COURT_ROOM_ID%%", + "selectedCourtCentre": { + "id": "%%COURT_CENTRE_ID%%", + "courtRoomId": "%%COURT_ROOM_ID%%", + "courtCentreName": "Test Court Centre", + "ouCode": "B01LY00" + }, + "type": { + "id": "4a0e892d-c0c5-3c51-95b8-704d8c781776", + "description": "Plea" + }, + "startDate": "%%TARGET_DATE%%", + "endDate": "%%TARGET_DATE%%", + "jurisdictionType": "CROWN", + "hearingLanguage": "ENGLISH", + "publicListNote": "", + "hasVideoLink": false, + "sendNotificationToParties": false, + "nonSittingDays": [], + "nonDefaultDays": [ + { + "startTime": "%%TARGET_DATE%%T09:00:00.000Z", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "roomId": "%%COURT_ROOM_ID%%", + "duration": 60, + "courtScheduleId": "%%NEW_COURT_SCHEDULE_ID%%" + } + ], + "judiciary": [] +} From 409b0c276625a5a2115a4025bb796c08f65e0a72 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 01:00:29 +0100 Subject: [PATCH 09/27] fix(SPRDT-1011): allocate CROWN schedule-only update by deriving courtroom from resolved final schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-hearing-for-listing with only a courtScheduleId on nonDefaultDays (no room fields) booked the session but never allocated: the handler resolved a null command-level courtroom, called removeCourtRoom, and canAllocateForCrown() stayed closed. Allocation only worked when the caller (UI) also supplied the room. Fix: inverse of stripRoomInfoIfAnyDraft (ADR-005) — when the command carries no courtroom and every enriched hearingDay resolved to a FINAL (isDraft=false) session in one distinct room, promote that room to the command level (and backfill a roomless selectedCourtCentre, which the handler prefers for CROWN). Draft sessions are roomless on every courtscheduler query path so they can never qualify; payload-supplied rooms are never overridden; multi-day spans across different rooms stay underived. Locked failing-first at both levels: CourtScheduleEnrichmentServiceTest (3 new derivation tests red->green + draft/parity/mixed-room pins, 146/146) and CrownUpdateHearingScheduleOnlyIT (schedule-only allocate + ADR-005 draft mirror, red 90s-timeout on allocated=false -> green 2/2). --- .../CourtScheduleEnrichmentService.java | 59 ++++ .../CourtScheduleEnrichmentServiceTest.java | 317 ++++++++++++++++++ .../it/CrownUpdateHearingScheduleOnlyIT.java | 242 +++++++++++++ ...listing-crown-singleday-schedule-only.json | 24 ++ 4 files changed, 642 insertions(+) create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java create mode 100644 listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 0b7bce076..e9a6d9989 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -16,6 +16,7 @@ import uk.gov.justice.listing.commands.HearingDay; import uk.gov.justice.listing.commands.HearingListingNeeds; import uk.gov.justice.listing.commands.UpdateHearingForListing; +import uk.gov.justice.listing.courts.SelectedCourtCentre; import uk.gov.justice.services.common.converter.JsonObjectToObjectConverter; import uk.gov.justice.services.common.converter.ObjectToJsonObjectConverter; import uk.gov.justice.services.messaging.JsonEnvelope; @@ -43,6 +44,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -347,6 +349,8 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL hearingBuilder.withJudiciary(convertJudicialRoleDomainToCore(enrichedJudiciaries)); } + deriveCommandLevelCourtRoomFromFinalSessions(hearing, enrichedHearingDays, hearingBuilder); + return hearingBuilder.build(); } @@ -454,6 +458,61 @@ private UpdateHearingForListing mergeCourtScheduleIdsFromNonDefaultDays(final Up .build(); } + /** + * Inverse of {@code HearingEnrichmentOrchestrator.stripRoomInfoIfAnyDraft} (ADR-005): a + * schedule-only update carries a courtScheduleId but no courtroom anywhere on the command. + * When every enriched hearingDay resolved to a FINAL (isDraft=false) session and those + * sessions all sit in the SAME room, that room is promoted to the command level (and onto a + * roomless selectedCourtCentre, which the handler prefers over the top-level field for + * CROWN). Without this the handler resolves a null courtroom, calls removeCourtRoom, and + * {@code Hearing.canAllocateForCrown()} never opens — the hearing silently stays unallocated + * even though courtscheduler firmly booked the session. + * + *

Draft sessions can never satisfy the derivation: courtscheduler strips the room from + * draft sessions on every query path, so a draft day is roomless here by construction — and + * any explicitly-draft day fails the {@code allFinal} check anyway. Payload-supplied rooms + * are never overridden. + */ + private void deriveCommandLevelCourtRoomFromFinalSessions(final UpdateHearingForListing hearing, + final List enrichedHearingDays, + final UpdateHearingForListing.Builder hearingBuilder) { + if (commandCarriesCourtRoom(hearing) || isEmpty(enrichedHearingDays)) { + return; + } + final boolean allFinalWithRoom = enrichedHearingDays.stream() + .allMatch(d -> Boolean.FALSE.equals(d.getIsDraft()) && nonNull(d.getCourtRoomId())); + if (!allFinalWithRoom) { + return; + } + final Set distinctRooms = enrichedHearingDays.stream() + .map(HearingDay::getCourtRoomId) + .collect(Collectors.toSet()); + if (distinctRooms.size() != 1) { + LOGGER.info("CROWN update: not deriving command-level courtRoomId for hearingId {} — {} distinct rooms across final sessions", + hearing.getHearingId(), distinctRooms.size()); + return; + } + final UUID derivedCourtRoomId = distinctRooms.iterator().next(); + LOGGER.info("CROWN update: derived command-level courtRoomId {} from resolved final session(s) for hearingId {}", + derivedCourtRoomId, hearing.getHearingId()); + hearingBuilder.withCourtRoomId(derivedCourtRoomId); + + final SelectedCourtCentre selectedCourtCentre = hearing.getSelectedCourtCentre(); + if (nonNull(selectedCourtCentre) && isNull(selectedCourtCentre.getCourtRoomId())) { + hearingBuilder.withSelectedCourtCentre(SelectedCourtCentre.selectedCourtCentre() + .withValuesFrom(selectedCourtCentre) + .withCourtRoomId(derivedCourtRoomId) + .build()); + } + } + + private static boolean commandCarriesCourtRoom(final UpdateHearingForListing hearing) { + if (nonNull(hearing.getCourtRoomId())) { + return true; + } + return nonNull(hearing.getSelectedCourtCentre()) && nonNull(hearing.getSelectedCourtCentre().getCourtRoomId()); + } + private UpdateHearingForListing handleCrownUpdateSearchAndBook(final UpdateHearingForListing hearing) { List hearingDaysWithCourtScheduleId = new ArrayList<>(); List judicialRolesBySearchAndBook = new ArrayList<>(); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 935be3467..549c91986 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -4291,6 +4291,323 @@ void enrichCrownUpdateHearing_shouldSkipMerge_whenNonDefaultDayCourtScheduleIdDa assertNull(result.getHearingDays().get(0).getCourtScheduleId()); } + // ─── schedule-only courtroom derivation (derive room from resolved FINAL schedule) ─── + // A schedule-only update payload carries a courtScheduleId but NO room fields. When the id + // resolves to a FINAL (isDraft=false) session, enrichment must derive the command-level + // courtroom from the resolved schedule so the handler assigns it and canAllocateForCrown() + // opens. Draft sessions stay roomless (ADR-005); payload-supplied rooms are never overridden. + + @Test + void enrichCrownUpdateHearing_scheduleOnly_shouldDeriveCommandLevelCourtRoomFromResolvedFinalSession() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + // Schedule-only shape: no hearingDays, no courtRoomId, no selectedCourtCentre — + // the courtScheduleId on nonDefaultDays is the only session reference. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // Day-level: room + isDraft=false from the resolved session (existing behaviour). + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(false)); + assertThat(result.getHearingDays().get(0).getCourtRoomId(), is(sessionCourtRoomId)); + // Command-level: the courtroom must be DERIVED from the resolved FINAL session so the + // handler assigns it (instead of removeCourtRoom) and canAllocateForCrown() opens. + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_scheduleOnly_shouldNotDeriveCommandLevelCourtRoom_whenSessionIsDraft() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + // Draft session: courtscheduler sanitises the room on every query path (ADR-005), + // so the by-id response carries NO courtRoomId. + final CourtSchedule draftSession = new CourtSchedule(); + draftSession.setCourtScheduleId(courtScheduleId.toString()); + draftSession.setCourtHouseId(courtHouseId.toString()); + draftSession.setSessionDate(hearingDate); + draftSession.setDraft(true); + draftSession.setHearingStartTime(startTime.toString()); + mockSingleDaySessionLookup(courtScheduleId, draftSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // Unallocated Crown hearings stay roomless at every level. + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertNull(result.getHearingDays().get(0).getCourtRoomId()); + assertNull(result.getCourtRoomId()); + } + + @Test + void enrichCrownUpdateHearing_shouldPreservePayloadCourtRoom_whenCommandAlreadyCarriesOne() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID payloadCourtRoomId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + // UI-parity shape: the payload already carries the room at command level. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withCourtRoomId(payloadCourtRoomId) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // A payload-supplied room is never overridden by the derivation. + assertThat(result.getCourtRoomId(), is(payloadCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_scheduleOnly_shouldBackfillRoomlessSelectedCourtCentre_fromResolvedFinalSession() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID selectedCourtCentreId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + // selectedCourtCentre present but ROOMLESS: for CROWN the handler prefers + // selectedCourtCentre.courtRoomId over the top-level field, so the derivation must + // backfill it too or the handler would still resolve a null room. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withSelectedCourtCentre(SelectedCourtCentre.selectedCourtCentre() + .withId(selectedCourtCentreId) + .withOuCode("OU123") + .build()) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + assertThat(result.getSelectedCourtCentre().getCourtRoomId(), is(sessionCourtRoomId)); + // The rest of selectedCourtCentre is preserved. + assertThat(result.getSelectedCourtCentre().getId(), is(selectedCourtCentreId)); + assertThat(result.getSelectedCourtCentre().getOuCode(), is("OU123")); + } + + @Test + void enrichCrownUpdateHearing_multiDay_shouldDeriveCommandLevelRoom_whenAllSessionsFinalAndSameRoom() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + // Multi-day (total > 360), no room anywhere in the payload. + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(360) + .build(), + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId2) + .withHearingDate(day1.plusDays(1)) + .withDurationMinutes(360) + .build())) + .build(); + + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, sessionCourtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, sessionCourtRoomId, courtHouseId, day1.plusDays(1), false); + mockMultiDaySearchAndBook(courtScheduleId1, courtScheduleId2, cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + // Every booked session is FINAL and sits in the SAME room — that room becomes the + // command-level courtroom. + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_multiDay_shouldNotDeriveCommandLevelRoom_whenSessionsSpanDifferentRooms() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(360) + .build(), + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId2) + .withHearingDate(day1.plusDays(1)) + .withDurationMinutes(360) + .build())) + .build(); + + // Two FINAL sessions in DIFFERENT rooms: ambiguous — no command-level room is derived. + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, UUID.randomUUID(), courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, UUID.randomUUID(), courtHouseId, day1.plusDays(1), false); + mockMultiDaySearchAndBook(courtScheduleId1, courtScheduleId2, cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + assertNull(result.getCourtRoomId()); + } + + /** + * Mocks the single-day CROWN update chain: GET court-schedules-by-id returning the given + * session, plus the listHearingInCourtSessions slot-deduction call. + */ + private void mockSingleDaySessionLookup(final UUID courtScheduleId, final CourtSchedule session) { + final JsonObject csResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", courtScheduleId.toString()))) + .build(); + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(csResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(session); + + mockListHearingInCourtSessions(courtScheduleId); + } + + /** + * Mocks the multi-day CROWN update chain: multiDaySearchAndBook returning the two given + * sessions, plus the listHearingInCourtSessions slot-deduction call. + */ + private void mockMultiDaySearchAndBook(final UUID courtScheduleId1, final UUID courtScheduleId2, + final CourtSchedule cs1, final CourtSchedule cs2) { + final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)) + .add(buildCsJson(cs2))) + .build(); + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(multiDayResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(cs1, cs2); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId1, "2026-03-16T10:00:00Z", 360)) + .add(buildListHearingJson(courtScheduleId2, "2026-03-17T10:00:00Z", 360))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder() + .add(courtScheduleId1.toString()) + .add(courtScheduleId2.toString()) + .build()); + } + + private void mockListHearingInCourtSessions(final UUID courtScheduleId) { + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId, "2026-03-16T10:00:00Z", 240))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder().add(courtScheduleId.toString()).build()); + } + @Test void handleCrownMultiDayExtension_rebuildsHearingDays_on200() { final UUID hearingId = UUID.randomUUID(); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java new file mode 100644 index 000000000..8e0ab73f8 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java @@ -0,0 +1,242 @@ +package uk.gov.moj.cpp.listing.it; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessionsForCourtSchedule; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubSearchCourtSchedulesByIdSession; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentre; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentreById; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtMappings; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataHearingTypes; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataOrganisationUnitById; + +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.it.util.RestPollerHelper; +import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; +import uk.gov.moj.cpp.listing.steps.data.CourtCentreData; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.io.InputStream; +import java.text.MessageFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; + +import com.jayway.jsonpath.matchers.JsonPathMatchers; +import uk.gov.justice.services.common.http.HeaderConstants; +import uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder; +import uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher; +import uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher; + +/** + * Crown Phase 2: update-hearing-for-listing with a SCHEDULE-ONLY payload — the nonDefaultDay + * carries a courtScheduleId but NO room fields (no top-level courtRoomId, no selectedCourtCentre, + * no roomId on the nonDefaultDay). + * + *

When the courtScheduleId resolves (via courtscheduler search.court-schedules-by-id) to a + * FINAL (isDraft=false) session, enrichment must take the courtroom from the resolved court + * schedule — not from the payload only — populate it on the hearing and its hearing days, and + * let allocation proceed: {@code canAllocateForCrown()} opens, hearing-allocated-for-listing-v2 + * fires, and the query view shows {@code allocated=true} with the hearing-level courtRoomId + * taken from the resolved schedule. Without the derivation the handler reads a null command-level + * courtroom, calls removeCourtRoom, and the hearing silently stays unallocated even though + * courtscheduler firmly booked the session. + * + *

Mirror case (ADR-005 "Strip courtroom information from unallocated Crown hearings"): when + * the courtScheduleId resolves to a DRAFT session, the hearing must stay UNALLOCATED and + * roomless. Courtscheduler's by-id responses carry a room ONLY for non-draft sessions (the + * CourtScheduleRoomSanitiser nulls rooms on draft sessions), which is exactly the signal the + * enrichment keys off. + * + *

Sibling {@link CrownUpdateHearingSingleDayIT} covers the room-bearing (UI-parity) payload; + * this class locks the schedule-only shape sent by API callers. + */ +public class CrownUpdateHearingScheduleOnlyIT extends AbstractIT { + + private static final String MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING = + "application/vnd.listing.command.update-hearing-for-listing+json"; + private static final String UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY = + "listing.command.update-hearing-for-listing"; + private static final String SCHEDULE_ONLY_PAYLOAD = + "test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json"; + + @Test + void shouldAllocateAndDeriveRoomFromSchedule_whenScheduleOnlyPayloadResolvesToFinalSession() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID scheduleCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID finalCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // FINAL session: by-id resolution returns isDraft=false AND the session's courtroom — + // the room exists ONLY here, never in the payload. + stubSearchCourtSchedulesByIdSession( + finalCourtScheduleId.toString(), + courtHouseId, + scheduleCourtRoomId, + targetDate, + sessionStart, + false); + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + finalCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + final ListCourtHearingSteps seedSteps = givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, scheduleCourtRoomId); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + scheduleOnlyPayload(courtCentreId, targetDate, finalCourtScheduleId), + getLoggedInHeader()); + + // The hearing must end ALLOCATED with the hearing-level courtroom DERIVED from the + // resolved schedule. Day-level assertions prove the schedule id/room landed on the day. + pollHearingView(hearingId) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(targetDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(true)), + JsonPathMatchers.withJsonPath("$.courtRoomId", + is(scheduleCourtRoomId.toString())), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtScheduleId", + is(finalCourtScheduleId.toString())), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtRoomId", + is(scheduleCourtRoomId.toString()))))); + seedSteps.verifyPublicEVentHearingChangesSaved(hearingId); + } + + @Test + void shouldStayUnallocatedAndRoomless_whenScheduleOnlyPayloadResolvesToDraftSession() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID draftCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // DRAFT session: by-id resolution returns isDraft=true and NO courtroom (draft sessions + // are room-sanitised on every courtscheduler query path — ADR-005 corollary). + stubSearchCourtSchedulesByIdSession( + draftCourtScheduleId.toString(), + courtHouseId, + null, + targetDate, + sessionStart, + true); + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + draftCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, UUID.randomUUID()); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + scheduleOnlyPayload(courtCentreId, targetDate, draftCourtScheduleId), + getLoggedInHeader()); + + // The update must project (startDate moves) with the hearing UNALLOCATED and ROOMLESS — + // the draft schedule id lands on the day, but no courtroom may appear at any level. + pollHearingView(hearingId) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(targetDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(false)), + JsonPathMatchers.hasNoJsonPath("$.courtRoomId"), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtScheduleId", + is(draftCourtScheduleId.toString())), + JsonPathMatchers.hasNoJsonPath("$.hearingDays[0].courtRoomId")))); + } + + private String scheduleOnlyPayload(final UUID courtCentreId, + final LocalDate targetDate, + final UUID courtScheduleId) throws Exception { + final Map placeholders = new HashMap<>(); + placeholders.put("%%COURT_CENTRE_ID%%", courtCentreId.toString()); + placeholders.put("%%TARGET_DATE%%", targetDate.toString()); + placeholders.put("%%COURT_SCHEDULE_ID%%", courtScheduleId.toString()); + return loadAndSubstitute(SCHEDULE_ONLY_PAYLOAD, placeholders); + } + + private uk.gov.justice.services.test.utils.core.http.RestPoller pollHearingView(final UUID hearingId) { + final String url = String.format("%s/%s", getBaseUri(), + MessageFormat.format(readConfig().getProperty("listing.search.hearing"), hearingId.toString())); + return RestPollerHelper.pollWithDefaults( + RequestParamsBuilder + .requestParams(url, "application/vnd.listing.search.hearing+json") + .withHeader(HeaderConstants.USER_ID, getLoggedInUser()) + .build()); + } + + private ListCourtHearingSteps givenARealHearingExists(final UUID hearingId) { + final ListCourtHearingSteps seedSteps = + new ListCourtHearingSteps(HearingsData.hearingsData(hearingId)); + seedSteps.whenCaseIsSubmittedForListing(); + seedSteps.verifyHearingIsCreated(hearingId, 2); + return seedSteps; + } + + private static void givenReferenceDataStubsForUpdateHearing(final UUID courtCentreId, final UUID courtRoomId) { + final CourtCentreData courtCentreData = new CourtCentreData( + courtCentreId, + LocalTime.of(10, 30), + "6:30", + courtRoomId, + "Test Court Centre"); + stubGetReferenceDataCourtCentre(courtCentreData); + stubGetReferenceDataCourtCentreById(courtCentreData); + stubGetReferenceDataCourtMappings(courtCentreData); + stubGetReferenceDataHearingTypes(UUID.randomUUID()); + stubGetReferenceDataOrganisationUnitById(courtCentreId); + } + + private static String loadAndSubstitute(final String classpathResource, + final Map placeholders) throws Exception { + try (InputStream in = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalStateException("Test payload not found on classpath: " + classpathResource); + } + String body = new String(in.readAllBytes()); + for (final Map.Entry e : placeholders.entrySet()) { + body = body.replace(e.getKey(), e.getValue()); + } + return body; + } + } + + private static String buildUpdateHearingUrl(final UUID hearingId) { + final String path = MessageFormat.format( + readConfig().getProperty(UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY), + hearingId.toString()); + return String.format("%s/%s", getBaseUri(), path); + } +} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json new file mode 100644 index 000000000..02abdeb16 --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json @@ -0,0 +1,24 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "type": { + "id": "4a0e892d-c0c5-3c51-95b8-704d8c781776", + "description": "Plea" + }, + "startDate": "%%TARGET_DATE%%", + "endDate": "%%TARGET_DATE%%", + "jurisdictionType": "CROWN", + "hearingLanguage": "ENGLISH", + "publicListNote": "", + "hasVideoLink": false, + "sendNotificationToParties": false, + "nonSittingDays": [], + "nonDefaultDays": [ + { + "startTime": "%%TARGET_DATE%%T09:00:00.000Z", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "duration": 20, + "courtScheduleId": "%%COURT_SCHEDULE_ID%%" + } + ], + "judiciary": [] +} From 6ca5fb073ee884eb8ab66ae17ff48c40301124c7 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 03:50:23 +0100 Subject: [PATCH 10/27] fix(SPRDT-1011): suppress CROWN allocation when update sessions cannot be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the CROWN update enrichment could not resolve or book the requested sessions (multi-day search or single-day fetch returning empty), it logged a WARN and returned the seeded hearingDays unchanged — carrying an unverified courtScheduleId and a payload room. The aggregate then allocated the hearing while courtscheduler's bookings were untouched, diverging the two services (observed live on ns-ste-ccm-34: multi-day allocate collapsed to a 1-day allocated hearing while all three draft bookings remained). Fix: markDaysDraftWhenSessionsUnresolved — on either empty-result path the enriched days are marked isDraft=true, so canAllocateForCrown() stays closed, stripRoomInfoIfAnyDraft (ADR-005) clears day rooms downstream, and the courtScheduleIds are preserved for traceability. Two failing-first unit tests lock the behaviour; the two tests pinning the old return-unchanged contract were retargeted. Full listing IT suite green (239 tests). --- .../CourtScheduleEnrichmentService.java | 30 +++++++++-- .../CourtScheduleEnrichmentServiceTest.java | 52 +++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index e9a6d9989..e03b33b94 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -285,8 +285,8 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL firstDay.getHearingDate() != null ? firstDay.getHearingDate().toString() : LocalDate.now().toString()); if (isEmpty(sessions)) { - LOGGER.warn("CROWN multi-day update: no sessions found for hearingId {}.", hearing.getHearingId()); - return hearing; + LOGGER.warn("CROWN multi-day update: no sessions found for hearingId {} — marking days draft so allocation stays closed.", hearing.getHearingId()); + return markDaysDraftWhenSessionsUnresolved(hearing); } final int daysNeeded = sessions.size(); @@ -322,8 +322,8 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL final List sessions = fetchCourtSchedulesByIds(courtScheduleIds); if (isEmpty(sessions)) { - LOGGER.warn("CROWN single-day update: failed to fetch court schedules for hearingId {}. Returning unchanged.", hearing.getHearingId()); - return hearing; + LOGGER.warn("CROWN single-day update: failed to fetch court schedules for hearingId {} — marking days draft so allocation stays closed.", hearing.getHearingId()); + return markDaysDraftWhenSessionsUnresolved(hearing); } final boolean allNonDraft = sessions.stream().noneMatch(CourtSchedule::isDraft); @@ -506,6 +506,28 @@ private void deriveCommandLevelCourtRoomFromFinalSessions(final UpdateHearingFor } } + /** + * Fail-safe for the CROWN update path when courtscheduler could not resolve or book the + * requested sessions (empty search/fetch result): every hearingDay is marked draft so + * {@code Hearing.canAllocateForCrown()} stays closed and the hearing cannot silently + * allocate onto unverified sessions — previously the seeded days (carrying an unresolved + * courtScheduleId and a payload room) sailed through and the read models diverged from + * courtscheduler's bookings. courtScheduleIds are preserved for traceability; + * {@code stripRoomInfoIfAnyDraft} (ADR-005) strips the day-level rooms downstream. + */ + private UpdateHearingForListing markDaysDraftWhenSessionsUnresolved(final UpdateHearingForListing hearing) { + if (isEmpty(hearing.getHearingDays())) { + return hearing; + } + final List guardedDays = hearing.getHearingDays().stream() + .map(day -> HearingDay.hearingDay().withValuesFrom(day).withIsDraft(Boolean.TRUE).build()) + .toList(); + return UpdateHearingForListing.updateHearingForListing() + .withValuesFrom(hearing) + .withHearingDays(guardedDays) + .build(); + } + private static boolean commandCarriesCourtRoom(final UpdateHearingForListing hearing) { if (nonNull(hearing.getCourtRoomId())) { return true; diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 549c91986..0f06ce79e 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -1440,13 +1440,14 @@ void shouldNotSetCourtRoomIdOnUpdateHearingDays_whenSessionCourtRoomIdIsNull() { } @Test - void shouldReturnUnchangedWhenCrownUpdateMultiDaySearchReturnsEmpty() { + void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateMultiDaySearchReturnsEmpty() { final UUID hearingId = UUID.randomUUID(); final UUID courtScheduleId = UUID.randomUUID(); final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() .withHearingId(hearingId) .withJurisdictionType(JurisdictionType.CROWN) + .withCourtRoomId(UUID.randomUUID()) .withHearingDays(Collections.singletonList( HearingDay.hearingDay() .withCourtScheduleId(courtScheduleId) @@ -1469,6 +1470,47 @@ void shouldReturnUnchangedWhenCrownUpdateMultiDaySearchReturnsEmpty() { verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); assertThat(result.getHearingId(), is(hearingId)); + // The requested sessions could not be booked/verified: every day must be marked draft so + // Hearing.canAllocateForCrown() stays closed and the hearing cannot silently allocate onto + // unverified sessions (the courtScheduleId is preserved for traceability). + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); + } + + @Test + void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateSingleDayFetchReturnsEmpty() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId) + .withHearingDate(LocalDate.now().plusDays(5)) + .withDurationMinutes(240) + .build())) + .build(); + + // Mock fetchCourtSchedulesByIds returning no sessions (courtscheduler could not resolve the id) + final JsonObject emptyResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder()) + .build(); + + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(emptyResponseJson); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); + // Unresolved session ⇒ day marked draft ⇒ allocation suppressed downstream. + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); } @Test @@ -3748,7 +3790,7 @@ void enrichCrownUpdateHearing_shouldReturnUnchanged_whenMultiDayAndNoCourtSchedu } @Test - void enrichCrownUpdateHearing_shouldReturnUnchanged_whenSingleDayAndFetchReturnsEmpty() { + void enrichCrownUpdateHearing_shouldMarkDaysDraft_whenSingleDayAndFetchReturnsEmpty() { final UUID hearingId = UUID.randomUUID(); final UUID courtScheduleId = UUID.randomUUID(); @@ -3778,7 +3820,11 @@ void enrichCrownUpdateHearing_shouldReturnUnchanged_whenSingleDayAndFetchReturns final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); - assertThat(result, is(hearing)); + // Unresolved session ⇒ days marked draft so the aggregate cannot allocate on them; + // everything else (id, courtScheduleId) is preserved. + assertThat(result.getHearingId(), is(hearingId)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); } From b257ef7496a7087eb1ebf71b0d7f86e7d2d38064 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 13:20:25 +0100 Subject: [PATCH 11/27] fix(SPRDT-1011): suppress CROWN allocation when booked multi-day block ignores the requested start date A crown.search.and.book response whose block starts on a different date than the command requested is no longer silently adopted: the days are re-marked draft (same fail-safe as unresolved sessions) so the hearing window cannot diverge from the caller's intent. --- .../CourtScheduleEnrichmentService.java | 13 +++ .../CourtScheduleEnrichmentServiceTest.java | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index e03b33b94..5e16fc0dd 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -289,6 +289,19 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL return markDaysDraftWhenSessionsUnresolved(hearing); } + final LocalDate requestedStartDate = hearing.getStartDate(); + final LocalDate bookedBlockStartDate = sessions.stream() + .map(CourtSchedule::getSessionDate) + .filter(d -> nonNull(d)) + .min(LocalDate::compareTo) + .orElse(null); + if (nonNull(requestedStartDate) && nonNull(bookedBlockStartDate) + && !requestedStartDate.equals(bookedBlockStartDate)) { + LOGGER.warn("CROWN multi-day update: booked block starts {} but the command requested {} for hearingId {} — courtscheduler did not honour the requested window; marking days draft so allocation stays closed.", + bookedBlockStartDate, requestedStartDate, hearing.getHearingId()); + return markDaysDraftWhenSessionsUnresolved(hearing); + } + final int daysNeeded = sessions.size(); final int durationPerDay = totalDuration / daysNeeded; final List expandedDays = sessions.stream().map(session -> { diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index 0f06ce79e..d912a3fc8 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -1478,6 +1478,93 @@ void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateMultiDaySearchRetur assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); } + @Test + void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateMultiDayBookedBlockStartsOnDifferentDate() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate requestedStart = LocalDate.now().plusDays(8); + final LocalDate bookedStart = LocalDate.now().plusDays(5); + + // Date-move: the command asks for a window starting on requestedStart. + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withStartDate(requestedStart) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId) + .withHearingDate(requestedStart) + .withDurationMinutes(720) + .build())) + .build(); + + // courtscheduler answers with a block starting on a DIFFERENT day (live J07 failure: its + // idempotency guard returned the hearing's OLD block instead of moving the window). + final CourtSchedule cs1 = buildCourtSchedule(UUID.randomUUID(), sessionCourtRoomId, courtHouseId, bookedStart, false); + final CourtSchedule cs2 = buildCourtSchedule(UUID.randomUUID(), sessionCourtRoomId, courtHouseId, bookedStart.plusDays(1), false); + final JsonObject responseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)) + .add(buildCsJson(cs2))) + .build(); + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(responseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + // A booked window that ignores the requested start date must NOT be silently adopted: + // the command's days stay, marked draft, so allocation stays closed and the divergence is + // an explicit deferred state instead of a corrupted hearing window. + verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); + assertThat(result.getStartDate(), is(requestedStart)); + } + + @Test + void enrichCrownUpdateHearing_multiDay_shouldEnrichNormally_whenBookedBlockStartsOnRequestedDate() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withStartDate(day1) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(360) + .build(), + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId2) + .withHearingDate(day1.plusDays(1)) + .withDurationMinutes(360) + .build())) + .build(); + + // Booked block starts exactly on the requested start date — enrichment proceeds normally. + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, sessionCourtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, sessionCourtRoomId, courtHouseId, day1.plusDays(1), false); + mockMultiDaySearchAndBook(courtScheduleId1, courtScheduleId2, cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + assertThat(result.getHearingDays().size(), is(2)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(false)); + assertThat(result.getHearingDays().get(1).getIsDraft(), is(false)); + } + @Test void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateSingleDayFetchReturnsEmpty() { final UUID hearingId = UUID.randomUUID(); From 7e3a8d93b9a3ffaafdfa44b5ac54fb0333a21ade Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 23:53:19 +0100 Subject: [PATCH 12/27] =?UTF-8?q?fix(SPRDT-1011):=20CROWN=20update=20nonDe?= =?UTF-8?q?faultDays=20contract=20=E2=80=94=20virtual=20day=20is=20block?= =?UTF-8?q?=20total=20+=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate CROWN update payload shape (CrownNonDefaultDaysValidator, 400 on violation): at most one virtual=true nonDefaultDay; startDate must equal the virtual day's date; genuine (non-virtual) days must fall within startDate..endDate - multi-day booking uses the virtual day's duration as the block TOTAL and its courtScheduleId/date as the anchor sent to courtscheduler, instead of summing every day (a mixed proxy+genuine payload double-counted, e.g. 1440+360=1800, and over-booked a 5th session past the requested endDate) - after session extraction, hearingDays whose date matches a genuine nonDefaultDay take that day's startTime (list-response times previously always won) --- .../CourtScheduleEnrichmentService.java | 85 +++++++++- .../service/CrownNonDefaultDaysValidator.java | 102 ++++++++++++ .../HearingEnrichmentOrchestrator.java | 2 + .../CourtScheduleEnrichmentServiceTest.java | 105 ++++++++++++ .../CrownNonDefaultDaysValidatorTest.java | 154 ++++++++++++++++++ 5 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidator.java create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 5e16fc0dd..40a6156a5 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -15,6 +15,7 @@ import uk.gov.justice.core.courts.RotaSlot; import uk.gov.justice.listing.commands.HearingDay; import uk.gov.justice.listing.commands.HearingListingNeeds; +import uk.gov.justice.listing.commands.NonDefaultDay; import uk.gov.justice.listing.commands.UpdateHearingForListing; import uk.gov.justice.listing.courts.SelectedCourtCentre; import uk.gov.justice.services.common.converter.JsonObjectToObjectConverter; @@ -261,9 +262,14 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL return hearing; } - final int totalDuration = hearing.getHearingDays().stream() - .mapToInt(d -> d.getDurationMinutes() != null ? d.getDurationMinutes() : 0) - .sum(); + // The single virtual=true nonDefaultDay (validated upstream by CrownNonDefaultDaysValidator) + // carries the block TOTAL duration; genuine nonDefaultDays describe dates already inside that + // window, so summing every day would double-count and over-book the block. + final Optional virtualAnchor = virtualAnchorNonDefaultDay(hearing); + final int totalDuration = virtualAnchor.map(NonDefaultDay::getDuration) + .orElseGet(() -> hearing.getHearingDays().stream() + .mapToInt(d -> d.getDurationMinutes() != null ? d.getDurationMinutes() : 0) + .sum()); final boolean isMultiDay = totalDuration > HearingDurationEnrichmentService.MINUTES_IN_DAY; EnrichmentResult enrichmentResult; @@ -272,17 +278,27 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL .filter(d -> nonNull(d.getCourtScheduleId())) .findFirst().orElse(null); - if (firstDay == null) { - LOGGER.error("CROWN multi-day update: no courtScheduleId on hearingDays for hearingId {}", hearing.getHearingId()); + // The anchor sent to courtscheduler is the virtual nonDefaultDay's courtScheduleId/date + // (its date == startDate, validated upstream); only payloads without a virtual day fall + // back to the first courtScheduleId-carrying hearingDay. + final String anchorCourtScheduleId = virtualAnchor + .map(NonDefaultDay::getCourtScheduleId) + .filter(id -> !isBlank(id)) + .orElseGet(() -> firstDay != null ? firstDay.getCourtScheduleId().toString() : null); + if (anchorCourtScheduleId == null) { + LOGGER.error("CROWN multi-day update: no anchor courtScheduleId on nonDefaultDays or hearingDays for hearingId {}", hearing.getHearingId()); return hearing; } + final LocalDate anchorDate = virtualAnchor + .map(nd -> nonNull(nd.getStartTime()) ? nd.getStartTime().toLocalDate() : null) + .orElseGet(() -> firstDay != null ? firstDay.getHearingDate() : null); final List sessions = multiDaySearchAndBook( - firstDay.getCourtScheduleId().toString(), + anchorCourtScheduleId, totalDuration, hearing.getHearingId().toString(), - hearing.getCourtCentreId() != null ? hearing.getCourtCentreId().toString() : firstDay.getCourtScheduleId().toString(), - firstDay.getHearingDate() != null ? firstDay.getHearingDate().toString() : LocalDate.now().toString()); + hearing.getCourtCentreId() != null ? hearing.getCourtCentreId().toString() : anchorCourtScheduleId, + anchorDate != null ? anchorDate.toString() : LocalDate.now().toString()); if (isEmpty(sessions)) { LOGGER.warn("CROWN multi-day update: no sessions found for hearingId {} — marking days draft so allocation stays closed.", hearing.getHearingId()); @@ -349,7 +365,8 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL enrichmentResult = listHearingSessionsAndExtractData(hearing.getHearingId(), sanityCheckedDays); } - final List enrichedHearingDays = enrichmentResult.getHearingDays(); + final List enrichedHearingDays = applyGenuineNonDefaultDayStartTimes( + enrichmentResult.getHearingDays(), hearing.getNonDefaultDays(), hearing.getHearingId()); final List enrichedJudiciaries = enrichmentResult.getJudiciaries(); UpdateHearingForListing.Builder hearingBuilder = UpdateHearingForListing.updateHearingForListing() @@ -471,6 +488,56 @@ private UpdateHearingForListing mergeCourtScheduleIdsFromNonDefaultDays(final Up .build(); } + /** + * The single block-descriptor virtual nonDefaultDay (virtual=true AND duration > one court + * day) is the frontend's block descriptor for a CROWN update: it carries the anchor + * courtScheduleId and the TOTAL block duration. Uniqueness and its date == startDate are + * enforced upstream by {@code CrownNonDefaultDaysValidator}. Per-day virtual proxies + * (duration ≤ MINUTES_IN_DAY, e.g. the court-room-change flow) deliberately do NOT qualify — + * their durations must keep summing like any other day. + */ + private static Optional virtualAnchorNonDefaultDay(final UpdateHearingForListing hearing) { + if (isEmpty(hearing.getNonDefaultDays())) { + return Optional.empty(); + } + return hearing.getNonDefaultDays().stream() + .filter(CrownNonDefaultDaysValidator::isBlockDescriptor) + .findFirst(); + } + + /** + * A genuine (non-virtual) nonDefaultDay says "this date starts at a different time". The booked + * sessions' own start times are applied by {@code combineSearchAndBookResponseAndListResponse}, + * so the override must run AFTER extraction: any enriched hearingDay whose date matches a genuine + * nonDefaultDay takes that day's startTime (endTime follows from the day's duration). + */ + private static List applyGenuineNonDefaultDayStartTimes(final List days, + final List nonDefaultDays, + final UUID hearingId) { + if (isEmpty(days) || isEmpty(nonDefaultDays)) { + return days; + } + final Map startTimeByDate = nonDefaultDays.stream() + .filter(nd -> !Boolean.TRUE.equals(nd.getVirtual())) + .filter(nd -> nonNull(nd.getStartTime())) + .collect(Collectors.toMap(nd -> nd.getStartTime().toLocalDate(), NonDefaultDay::getStartTime, (first, second) -> first)); + if (startTimeByDate.isEmpty()) { + return days; + } + return days.stream().map(day -> { + final ZonedDateTime override = nonNull(day.getHearingDate()) ? startTimeByDate.get(day.getHearingDate()) : null; + if (override == null || override.equals(day.getStartTime())) { + return day; + } + LOGGER.info("CROWN update: applying non-default start time {} to hearingDay {} for hearingId {}", + override, day.getHearingDate(), hearingId); + return HearingDay.hearingDay().withValuesFrom(day) + .withStartTime(override) + .withEndTime(nonNull(day.getDurationMinutes()) ? override.plusMinutes(day.getDurationMinutes()) : day.getEndTime()) + .build(); + }).toList(); + } + /** * Inverse of {@code HearingEnrichmentOrchestrator.stripRoomInfoIfAnyDraft} (ADR-005): a * schedule-only update carries a courtScheduleId but no courtroom anywhere on the command. diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidator.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidator.java new file mode 100644 index 000000000..58649ef19 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidator.java @@ -0,0 +1,102 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static java.util.Objects.nonNull; +import static org.apache.commons.collections.CollectionUtils.isEmpty; +import static uk.gov.moj.cpp.listing.command.api.service.HearingDurationEnrichmentService.MINUTES_IN_DAY; + +import uk.gov.justice.listing.commands.NonDefaultDay; +import uk.gov.justice.listing.commands.UpdateHearingForListing; +import uk.gov.justice.services.adapter.rest.exception.BadRequestException; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; + +/** + * Payload-shape rules for CROWN update-hearing-for-listing nonDefaultDays. + * + *

Two DIFFERENT virtual=true shapes exist in the wild and must be told apart by duration: + *

    + *
  • Block descriptor — ONE virtual day whose duration spans the whole block + * (> {@code MINUTES_IN_DAY}) and whose courtScheduleId anchors the multi-day booking + * (the frontend change-hearing-details shape). The rules below apply to this shape.
  • + *
  • Per-day proxies — N virtual days each ≤ one court day, carrying per-day room/slot + * info (e.g. the court-room-change flow, HearingDayCourtRoomChangeForCrownIT). These are + * filtered out before the aggregate like any proxy and are NOT constrained here.
  • + *
+ * + * Rules for the block-descriptor shape (violations are caller errors → 400, mirroring the + * date checks in ListingCommandApi): + *
    + *
  • at most ONE block descriptor per payload;
  • + *
  • the command's startDate must equal the descriptor's date (it anchors the block);
  • + *
  • genuine days (virtual absent/false) must fall within startDate..endDate. Enforced only + * when a descriptor is present — legacy callers without one send stale out-of-window + * genuine days and rely on the long-standing silent filter in + * {@code HearingDaysEnrichmentService.getValidNonDefaultDays}.
  • + *
+ */ +public final class CrownNonDefaultDaysValidator { + + private CrownNonDefaultDaysValidator() { + } + + static void validateForCrownUpdate(final UpdateHearingForListing hearing) { + final List nonDefaultDays = hearing.getNonDefaultDays(); + if (isEmpty(nonDefaultDays)) { + return; + } + + final List blockDescriptors = nonDefaultDays.stream() + .filter(CrownNonDefaultDaysValidator::isBlockDescriptor) + .toList(); + + if (blockDescriptors.size() > 1) { + throw new BadRequestException( + "CROWN update-hearing-for-listing: at most one virtual nonDefaultDay may carry the block total (duration > " + + MINUTES_IN_DAY + "), found " + blockDescriptors.size() + + " for hearingId " + hearing.getHearingId()); + } + + if (blockDescriptors.isEmpty()) { + return; + } + + final LocalDate startDate = hearing.getStartDate(); + final ZonedDateTime descriptorStartTime = blockDescriptors.get(0).getStartTime(); + if (nonNull(startDate) && nonNull(descriptorStartTime) + && !startDate.equals(descriptorStartTime.toLocalDate())) { + throw new BadRequestException( + "CROWN update-hearing-for-listing: startDate " + startDate + + " must equal the virtual nonDefaultDay's date " + descriptorStartTime.toLocalDate() + + " for hearingId " + hearing.getHearingId()); + } + + final LocalDate endDate = hearing.getEndDate(); + if (nonNull(startDate) && nonNull(endDate)) { + final List outsideWindow = nonDefaultDays.stream() + .filter(nd -> !Boolean.TRUE.equals(nd.getVirtual())) + .map(NonDefaultDay::getStartTime) + .filter(t -> nonNull(t)) + .map(ZonedDateTime::toLocalDate) + .filter(d -> d.isBefore(startDate) || d.isAfter(endDate)) + .toList(); + if (!outsideWindow.isEmpty()) { + throw new BadRequestException( + "CROWN update-hearing-for-listing: nonDefaultDays without virtual=true must fall within startDate " + + startDate + " and endDate " + endDate + "; outside: " + outsideWindow + + " for hearingId " + hearing.getHearingId()); + } + } + } + + /** + * A virtual day that claims to describe the whole multi-day block: its duration exceeds one + * court day. Per-day virtual proxies (duration ≤ MINUTES_IN_DAY) do not qualify. + */ + static boolean isBlockDescriptor(final NonDefaultDay nonDefaultDay) { + return Boolean.TRUE.equals(nonDefaultDay.getVirtual()) + && nonNull(nonDefaultDay.getDuration()) + && nonDefaultDay.getDuration() > MINUTES_IN_DAY; + } +} diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java index 3540314f7..0342b42ca 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java @@ -107,6 +107,7 @@ public UpdateHearingForListing enrichUpdateHearingForListing(UpdateHearingForLis enrichedHearing = courtScheduleEnrichmentService.enrichWithCourtSchedules(withDuration,envelope); } else if (JurisdictionType.CROWN.equals(jurisdictionType)) { LOGGER.info("Enrich update hearing for CROWN hearingid: {}", hearing.getHearingId()); + CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing); if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { // CROWN with courtScheduleId submitted (hearingDays or nonDefaultDays): CourtSchedule-first // flow, mirroring enrichListCourtHearing. The submitted ids ARE the chosen sessions, so we @@ -149,6 +150,7 @@ public UpdateHearingForListing enrichUpdateHearingForListing(UpdateHearingForLis enrichedHearing = courtScheduleEnrichmentService.enrichWithCourtSchedules(withDuration,envelope); } else if (JurisdictionType.CROWN.equals(jurisdictionType)) { LOGGER.info("Enrich update hearing for CROWN hearingid: {}", hearing.getHearingId()); + CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing); if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { // courtScheduleId submitted → CourtSchedule-first flow (pre-d62d3446 behaviour). // See enrichUpdateHearingForListing(hearing, envelope) for rationale. diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java index d912a3fc8..bf760b4d0 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java @@ -14,6 +14,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,6 +46,7 @@ import java.util.Date; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.UUID; import javax.json.JsonObject; @@ -53,6 +55,7 @@ import org.apache.http.HttpStatus; 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; @@ -1565,6 +1568,108 @@ void enrichCrownUpdateHearing_multiDay_shouldEnrichNormally_whenBookedBlockStart assertThat(result.getHearingDays().get(1).getIsDraft(), is(false)); } + @Test + void enrichCrownUpdateHearing_multiDay_virtualNonDefaultDayCarriesTotalAndAnchor_genuineDayGetsItsStartTime() { + final UUID hearingId = UUID.randomUUID(); + final UUID anchorCsId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(10); + final LocalDate genuineDay = day1.plusDays(2); + + // Frontend multi-day shape (real steccm22 payload): NO hearingDays; one virtual=true proxy + // carrying the block TOTAL (1440 = 4 days) + the anchor csId, plus one genuine nonDefaultDay + // inside the window asking for a 09:00 start on its date. Summing both (1800) would over-book + // a 5th day past the requested endDate. + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withCourtCentreId(courtCentreId) + .withStartDate(day1) + .withEndDate(day1.plusDays(3)) + .withNonDefaultDays(Arrays.asList( + NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(day1 + "T09:00:00Z")) + .withDuration(1440) + .withCourtScheduleId(anchorCsId.toString()) + .withCourtCentreId(courtCentreId.toString()) + .withVirtual(Boolean.TRUE) + .build(), + NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(genuineDay + "T09:00:00Z")) + .withDuration(360) + .withCourtCentreId(courtCentreId.toString()) + .build())) + .build(); + + final UUID cs2Id = UUID.randomUUID(); + final UUID cs3Id = UUID.randomUUID(); + final UUID cs4Id = UUID.randomUUID(); + final CourtSchedule cs1 = buildCourtSchedule(anchorCsId, sessionCourtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(cs2Id, sessionCourtRoomId, courtHouseId, day1.plusDays(1), false); + final CourtSchedule cs3 = buildCourtSchedule(cs3Id, sessionCourtRoomId, courtHouseId, genuineDay, false); + final CourtSchedule cs4 = buildCourtSchedule(cs4Id, sessionCourtRoomId, courtHouseId, day1.plusDays(3), false); + + final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)).add(buildCsJson(cs2)).add(buildCsJson(cs3)).add(buildCsJson(cs4))) + .build(); + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(multiDayResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(cs1, cs2, cs3, cs4); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(anchorCsId, day1 + "T10:00:00Z", 360)) + .add(buildListHearingJson(cs2Id, day1.plusDays(1) + "T10:00:00Z", 360)) + .add(buildListHearingJson(cs3Id, genuineDay + "T10:00:00Z", 360)) + .add(buildListHearingJson(cs4Id, day1.plusDays(3) + "T10:00:00Z", 360))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder() + .add(anchorCsId.toString()).add(cs2Id.toString()).add(cs3Id.toString()).add(cs4Id.toString()) + .build()); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + // The virtual day's duration IS the block total (1440, not 1440+360) and its csId/date anchor the call. + @SuppressWarnings("unchecked") + final ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(hearingSlotsService, atLeastOnce()).multiDaySearchAndBook(paramsCaptor.capture()); + final Map params = paramsCaptor.getValue(); + assertThat(params.get(CourtScheduleEnrichmentService.DURATION_MINUTES), is("1440")); + assertThat(params.get("courtScheduleId"), is(anchorCsId.toString())); + assertThat(params.get(CourtScheduleEnrichmentService.HEARING_DATE), is(day1.toString())); + + // 4 booked days; the genuine nonDefaultDay's date keeps ITS start time (09:00, endTime follows), + // every other day keeps the session start time (10:00). + assertThat(result.getHearingDays().size(), is(4)); + final HearingDay genuine = result.getHearingDays().stream() + .filter(d -> genuineDay.equals(d.getHearingDate())).findFirst().orElseThrow(); + assertThat(genuine.getStartTime(), is(ZonedDateTime.parse(genuineDay + "T09:00:00Z"))); + assertThat(genuine.getEndTime(), is(ZonedDateTime.parse(genuineDay + "T15:00:00Z"))); + final HearingDay anchorDay = result.getHearingDays().stream() + .filter(d -> day1.equals(d.getHearingDate())).findFirst().orElseThrow(); + assertThat(anchorDay.getStartTime(), is(ZonedDateTime.parse(day1 + "T10:00:00Z"))); + } + @Test void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateSingleDayFetchReturnsEmpty() { final UUID hearingId = UUID.randomUUID(); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java new file mode 100644 index 000000000..59642ba16 --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java @@ -0,0 +1,154 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import uk.gov.justice.core.courts.JurisdictionType; +import uk.gov.justice.listing.commands.NonDefaultDay; +import uk.gov.justice.listing.commands.UpdateHearingForListing; +import uk.gov.justice.services.adapter.rest.exception.BadRequestException; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class CrownNonDefaultDaysValidatorTest { + + private static final LocalDate START_DATE = LocalDate.now().plusDays(10); + private static final LocalDate END_DATE = START_DATE.plusDays(3); + + @Test + void shouldRejectMoreThanOneBlockDescriptorVirtualDay() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + virtualDay(START_DATE.plusDays(1), 720))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("at most one virtual nonDefaultDay may carry the block total")); + } + + @Test + void shouldAcceptMultiplePerDayVirtualProxies() { + // Court-room-change shape (HearingDayCourtRoomChangeForCrownIT): N virtual days each ≤ one + // court day, dates inside the window but NOT on startDate — must stay accepted. + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE.plusDays(2), 360), + virtualDay(START_DATE.plusDays(3), 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptGenuineDayOutsideWindowWhenNoBlockDescriptor() { + // Legacy shape (HearingIT.updateHearingResultsWhenCourtRoomNotSelected): a stale genuine + // day outside the window with no block descriptor relies on the silent enrichment filter. + final UpdateHearingForListing hearing = crownUpdate(Collections.singletonList( + genuineDay(START_DATE.minusYears(6), 15))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldRejectWhenStartDateDiffersFromVirtualDayDate() { + final UpdateHearingForListing hearing = crownUpdate(Collections.singletonList( + virtualDay(START_DATE.plusDays(1), 1440))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("must equal the virtual nonDefaultDay's date")); + } + + @Test + void shouldRejectGenuineNonDefaultDayOutsideWindow() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(END_DATE.plusDays(1), 360))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("must fall within startDate")); + } + + @Test + void shouldRejectGenuineNonDefaultDayBeforeWindowWhenBlockDescriptorPresent() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE.minusDays(1), 360))); + + assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptValidMixedPayload() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE.plusDays(2), 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptGenuineDaysOnWindowBoundaries() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE, 360), + genuineDay(END_DATE, 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptWhenNoNonDefaultDays() { + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(crownUpdate(null))); + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(crownUpdate(Collections.emptyList()))); + } + + @Test + void shouldSkipDateRulesWhenStartDateAbsent() { + // weekCommencing shape: no startDate/endDate on the command — only the uniqueness rule applies. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withHearingId(UUID.randomUUID()) + .withJurisdictionType(JurisdictionType.CROWN) + .withNonDefaultDays(Arrays.asList( + virtualDay(START_DATE.plusDays(5), 1440), + genuineDay(START_DATE.plusDays(20), 360))) + .build(); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + private static UpdateHearingForListing crownUpdate(final List nonDefaultDays) { + return UpdateHearingForListing.updateHearingForListing() + .withHearingId(UUID.randomUUID()) + .withJurisdictionType(JurisdictionType.CROWN) + .withStartDate(START_DATE) + .withEndDate(END_DATE) + .withNonDefaultDays(nonDefaultDays) + .build(); + } + + private static NonDefaultDay virtualDay(final LocalDate date, final int duration) { + return NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(date + "T09:00:00Z")) + .withDuration(duration) + .withCourtScheduleId(UUID.randomUUID().toString()) + .withVirtual(Boolean.TRUE) + .build(); + } + + private static NonDefaultDay genuineDay(final LocalDate date, final int duration) { + return NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(date + "T09:00:00Z")) + .withDuration(duration) + .build(); + } +} From 0735d1172dbd18926cac9dfc537b7609dd5f67b3 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:09:28 +0100 Subject: [PATCH 13/27] feat(SPRDT-987): move-hearing-to-past-date wrapper (MAGS via courtscheduler, CROWN listing-side, past-only) Adds listing.command.move-hearing-to-past-date as a new content-type action on the existing POST /hearings/{hearingId} resource. ListingCommandApi resolves the hearing from the listing viewstore (new HearingLookupService, mirroring the existing command.service.HearingService query-view lookup pattern) and rejects unknown hearingIds with 422 HEARING_ID_NOT_FOUND before anything is sent. MAGISTRATES hearings call courtscheduler synchronously via CourtSchedulerServiceAdapter.moveHearingToPastDate (new HearingSlotsService POST to /hearings/{hearingId}, application/vnd.courtscheduler.move-hearing-to-past-date+json); a 2xx response enriches the command with a purpose-built MoveHearingToPastDateResult and the enriched handler applies it via the existing raiseHearingDayCourtSchedulesUpdated/HearingDayCourtScheduleUpdated event. A non-2xx response raises MoveHearingToPastDateException, rendered as 422/404 by the new MoveHearingToPastDateExceptionMapper (registered via the new ListingCommandCommonProviders @Specializes DefaultCommonProviders) - no event is sent. CROWN hearings never call courtscheduler (Baris decision D1): the command-api validates past-only (422 FUTURE_DATE_NOT_ALLOWED) and the enriched handler re-dates the hearing purely listing-side by reusing the existing Hearing.changeStartDate aggregate method (StartDateChangedForHearing event) - no new domain event needed. Permission: PermissionConstants.createChangeHearingToPastDatePermission() (object "Change hearing to past date", action "Link") plus a DRL rule using userAndGroupProvider.hasPermission(...). Adds MoveHearingToPastDateIT plus CourtSchedulerServiceStub/WireMockStubUtils extensions and MAGS/CROWN test-data fixtures. --- listing-command/listing-command-api/pom.xml | 5 + .../command/api/ListingCommandApi.java | 95 +++++++++ .../accesscontrol/PermissionConstants.java | 12 +- .../mapper/ListingCommandCommonProviders.java | 1 + .../MoveHearingToPastDateExceptionMapper.java | 46 +++++ .../api/service/HearingLookupService.java | 60 ++++++ .../api/accesscontrol/listing-command-api.drl | 2 +- ...ing.command.move-hearing-to-past-date.json | 5 + ...ing.command.move-hearing-to-past-date.json | 23 +++ .../src/raml/listing-command-api.raml | 6 + .../command/api/ListingAccessControlTest.java | 9 +- .../command/api/ListingCommandApiTest.java | 181 ++++++++++++++++++ .../PermissionConstantsTest.java | 8 +- ...eHearingToPastDateExceptionMapperTest.java | 74 +++++++ .../api/service/HearingLookupServiceTest.java | 75 ++++++++ ...hange-hearing-to-past-date-permission.json | 6 + .../handler/ListingCommandHandler.java | 30 +++ ...nd.move-hearing-to-past-date-enriched.json | 11 ++ ...nd.move-hearing-to-past-date-enriched.json | 43 +++++ .../listing-command-handler.messaging.raml | 4 + .../handler/ListingCommandHandlerTest.java | 48 +++++ .../MoveHearingToPastDateException.java | 38 ++++ .../pastdate/MoveHearingToPastDateResult.java | 18 ++ .../service/CourtSchedulerServiceAdapter.java | 52 +++++ .../common/service/HearingSlotsService.java | 39 ++++ ...rviceAdapterMoveHearingToPastDateTest.java | 118 ++++++++++++ .../service/HearingSlotsServiceTest.java | 74 +++++++ .../listing/it/MoveHearingToPastDateIT.java | 145 ++++++++++++++ .../steps/MoveHearingToPastDateSteps.java | 111 +++++++++++ .../utils/CourtSchedulerServiceStub.java | 64 +++++++ .../cpp/listing/utils/WireMockStubUtils.java | 22 +++ .../src/test/resources/endpoint.properties | 1 + .../move-hearing-to-past-date.json | 5 + .../move-hearing-to-past-date.json | 5 + 34 files changed, 1421 insertions(+), 15 deletions(-) create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java create mode 100644 listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json create mode 100644 listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java create mode 100644 listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json create mode 100644 listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json create mode 100644 listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json create mode 100644 listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java create mode 100644 listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java create mode 100644 listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java create mode 100644 listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json create mode 100644 listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json diff --git a/listing-command/listing-command-api/pom.xml b/listing-command/listing-command-api/pom.xml index fd3ddfde7..3c14dd8e2 100644 --- a/listing-command/listing-command-api/pom.xml +++ b/listing-command/listing-command-api/pom.xml @@ -140,6 +140,11 @@ listing-domain-aggregate ${project.version} + + uk.gov.moj.cpp.listing + listing-query-view + ${project.version} + diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index b3d3c2572..3321f5879 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -39,9 +39,14 @@ import uk.gov.justice.services.messaging.JsonEnvelope; import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory; import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator; +import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; +import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter; import uk.gov.moj.cpp.listing.common.service.HearingSlotsService; import uk.gov.moj.cpp.listing.domain.VacateTrialEnriched; +import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -71,6 +76,23 @@ public class ListingCommandApi { private static final String LISTING_COMMAND_LIST_UNSCHEDULED_NEXT_HEARINGS_ENRICHED = "listing.command.list-unscheduled-next-hearings-enriched"; private static final String LISTING_COMMAND_EXTEND_HEARING_FOR_HEARING_ENRICHED = "listing.command.extend-hearing-for-hearing-enriched"; private static final String LISTING_COMMAND_VACATE_TRIAL = "listing.command.vacate-trial-enriched"; + private static final String LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED = "listing.command.move-hearing-to-past-date-enriched"; + private static final String COURT_CENTRE_ID = "courtCentreId"; + private static final String START_DATE = "startDate"; + private static final String JURISDICTION = "jurisdiction"; + private static final String JURISDICTION_TYPE = "jurisdictionType"; + private static final String ESTIMATED_MINUTES = "estimatedMinutes"; + private static final String COURT_SCHEDULE_ID = "courtScheduleId"; + private static final String COURT_ROOM_ID = "courtRoomId"; + private static final String SESSION_DATE = "sessionDate"; + private static final String SESSION_START_TIME = "sessionStartTime"; + private static final String SESSION_END_TIME = "sessionEndTime"; + private static final String DURATION_IN_MINUTES = "durationInMinutes"; + private static final String ERROR_CODE = "errorCode"; + private static final String MESSAGE = "message"; + public static final String HEARING_ID_NOT_FOUND = "HEARING_ID_NOT_FOUND"; + public static final String FUTURE_DATE_NOT_ALLOWED = "FUTURE_DATE_NOT_ALLOWED"; + private static final String CROWN_JURISDICTION = "CROWN"; private static final String LISTING_COMMAND_CORRECT_HEARING_DAYS_WO_CC = "listing.command.correct-hearing-days-without-court-centre"; private static final String LISTING_COMMAND_DUPLICATE_UNALLOCATED_HEARING = "listing.command.mark-unallocated-hearing-as-duplicate"; private static final String LISTING_COMMAND_UPDATE_EXISTING_HEARING = "listing.command.update-existing-hearing"; @@ -102,6 +124,10 @@ public class ListingCommandApi { private HearingSlotsService hearingSlotsService; @Inject private HearingEnrichmentOrchestrator hearingEnrichmentOrchestrator; + @Inject + private CourtSchedulerServiceAdapter courtSchedulerServiceAdapter; + @Inject + private HearingLookupService hearingLookupService; @Handles("listing.command.list-court-hearing") public void handleListCourtHearing(final JsonEnvelope envelope) { @@ -332,6 +358,75 @@ public void handleVacateTrial(final JsonEnvelope envelope) { envelope.payload())); } + @Handles("listing.command.move-hearing-to-past-date") + public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { + final JsonObject payload = envelope.payloadAsJsonObject(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("'listing.command.move-hearing-to-past-date' received with payload {}", envelope.toObfuscatedDebugString()); + } + + final UUID hearingId = fromString(payload.getString(HEARING_ID)); + final UUID courtCentreId = fromString(payload.getString(COURT_CENTRE_ID)); + final LocalDate startDate = LocalDate.parse(payload.getString(START_DATE)); + + final JsonObject hearing = hearingLookupService.findHearing(hearingId, envelope) + .orElseThrow(() -> new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(HEARING_ID_NOT_FOUND, "No hearing found for hearingId " + hearingId), + "No hearing found for hearingId " + hearingId)); + + final String jurisdictionType = hearing.getString(JURISDICTION_TYPE, null); + + final JsonObjectBuilder enrichedBuilder = createObjectBuilder() + .add(HEARING_ID, hearingId.toString()) + .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType) + .add(START_DATE, startDate.toString()); + + if (CROWN_JURISDICTION.equals(jurisdictionType)) { + // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. + if (startDate.isAfter(LocalDate.now())) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), + "Hearings can only be moved to today or an earlier date"); + } + } else { + final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) + ? hearing.getInt(ESTIMATED_MINUTES) : null; + + final MoveHearingToPastDateResult slot = + courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); + + if (slot.courtScheduleId() != null) { + enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); + } + if (slot.courtRoomId() != null) { + enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); + } + if (slot.sessionDate() != null) { + enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); + } + if (slot.sessionStartTime() != null) { + enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); + } + if (slot.sessionEndTime() != null) { + enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); + } + if (slot.durationInMinutes() != null) { + enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); + } + } + + sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED), + enrichedBuilder.build())); + } + + private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) { + return createObjectBuilder() + .add(ERROR_CODE, errorCode) + .add(MESSAGE, message) + .build(); + } + @Handles("listing.command.extend-hearing-for-hearing") public void handleExtendHearingForHearing(final JsonEnvelope envelope) { diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java index 4938ca466..97bb9ea37 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java @@ -9,21 +9,21 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -@JsonPropertyOrder({"object","action","key","keyWithOutSource"}) +@JsonPropertyOrder({"object", "action", "key", "keyWithOutSource"}) public final class PermissionConstants { private static final ObjectMapper objectMapper = new ObjectMapperProducer().objectMapper(); - private static final String COURT_SCHEDULE_OBJECT = "CourtSchedule"; - private static final String CREATE_ACTION = "Create"; + private static final String CHANGE_HEARING_TO_PAST_DATE_OBJECT = "Change hearing to past date"; + private static final String LINK_ACTION = "Link"; private PermissionConstants() { } - public static String createCourtSchedulePermission() throws JsonProcessingException { + public static String createChangeHearingToPastDatePermission() throws JsonProcessingException { final ExpectedPermission expectedPermission = builder() - .withObject(COURT_SCHEDULE_OBJECT) - .withAction(CREATE_ACTION) + .withObject(CHANGE_HEARING_TO_PAST_DATE_OBJECT) + .withAction(LINK_ACTION) .build(); return objectMapper.writeValueAsString(expectedPermission); diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java index 1c6ed708c..029639a53 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java @@ -28,6 +28,7 @@ public class ListingCommandCommonProviders extends DefaultCommonProviders { public Set> providers() { final Set> providers = super.providers(); providers.add(CrownMultiDayExtensionExceptionMapper.class); + providers.add(MoveHearingToPastDateExceptionMapper.class); return providers; } } diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java new file mode 100644 index 000000000..ecbe10515 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java @@ -0,0 +1,46 @@ +package uk.gov.moj.cpp.listing.command.api.mapper; + +import static javax.json.Json.createObjectBuilder; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.Response.status; +import static uk.gov.justice.services.messaging.JsonObjects.getString; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; + +import javax.inject.Inject; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import org.slf4j.Logger; + +@Provider +public class MoveHearingToPastDateExceptionMapper implements ExceptionMapper { + + @Inject + Logger logger; + + @Override + public Response toResponse(final MoveHearingToPastDateException exception) { + logger.debug("move-hearing-to-past-date rejected", exception); + + final JsonObjectBuilder builder = createObjectBuilder(); + if (exception.getErrorCode() != null) { + builder.add("errorCode", exception.getErrorCode()); + } + final JsonObject responseBody = exception.getResponseBody(); + final String message = responseBody == null + ? exception.getMessage() + : getString(responseBody, "message").orElse(exception.getMessage()); + if (message != null) { + builder.add("message", message); + } + + return status(exception.getHttpStatus()) + .entity(builder.build().toString()) + .type(APPLICATION_JSON) + .build(); + } +} diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java new file mode 100644 index 000000000..b3420ca66 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java @@ -0,0 +1,60 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static uk.gov.justice.services.core.annotation.Component.QUERY_API; +import static uk.gov.justice.services.messaging.JsonEnvelope.metadataFrom; +import static uk.gov.justice.services.messaging.JsonObjects.createObjectBuilder; + +import uk.gov.justice.services.core.annotation.ServiceComponent; +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.messaging.JsonEnvelope; +import uk.gov.justice.services.messaging.Metadata; +import uk.gov.moj.cpp.listing.query.view.HearingQueryView; + +import java.util.Optional; +import java.util.UUID; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.json.JsonObject; +import javax.ws.rs.NotFoundException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Synchronous, in-process lookup of a hearing from the listing viewstore, for command-api-side + * existence/validation checks that must happen before a command is sent (e.g. + * move-hearing-to-past-date's unknown-hearingId 422). Mirrors the + * {@code uk.gov.moj.cpp.listing.command.service.HearingService} pattern already used from + * listing-command-handler, moved into command-api since the check has to happen here to be able + * to reject with a synchronous 422 before anything is sent. + */ +@ApplicationScoped +public class HearingLookupService { + + private static final Logger LOGGER = LoggerFactory.getLogger(HearingLookupService.class); + private static final String HEARING_QUERY_BY_HEARING_ID = "listing.search.hearing"; + + @Inject + private Enveloper enveloper; + + @Inject + @ServiceComponent(QUERY_API) + private HearingQueryView hearingQueryView; + + public Optional findHearing(final UUID hearingId, final JsonEnvelope envelope) { + final JsonObject payload = createObjectBuilder().add("id", hearingId.toString()).build(); + + final Metadata metadata = metadataFrom(envelope.metadata()).withName(HEARING_QUERY_BY_HEARING_ID).build(); + final JsonEnvelope query = JsonEnvelope.envelopeFrom(metadata, payload); + final JsonEnvelope request = enveloper.withMetadataFrom(query, HEARING_QUERY_BY_HEARING_ID).apply(payload); + + try { + final JsonEnvelope response = hearingQueryView.getHearingById(request); + return Optional.of(response.payloadAsJsonObject()); + } catch (final NotFoundException e) { + LOGGER.debug("No hearing found for hearingId {}", hearingId, e); + return Optional.empty(); + } + } +} diff --git a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl index ba17af205..6ddd8d482 100644 --- a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl +++ b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl @@ -253,7 +253,7 @@ rule "Command - API - Action - listing.command.move-hearing-to-past-date" when $outcome: Outcome(); $action: Action(name == "listing.command.move-hearing-to-past-date"); - eval(userAndGroupProvider.hasPermission($action, PermissionConstants.createCourtSchedulePermission())); + eval(userAndGroupProvider.hasPermission($action, PermissionConstants.createChangeHearingToPastDatePermission())); then $outcome.setSuccess(true); end diff --git a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json new file mode 100644 index 000000000..1f6e0d5ff --- /dev/null +++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "0baecac5-222b-402d-9047-84803679edae", + "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", + "startDate": "2026-05-01" +} diff --git a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json new file mode 100644 index 000000000..d77eddfc5 --- /dev/null +++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date.json", + "type": "object", + "properties": { + "hearingId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "startDate": { + "type": "string", + "format": "date" + } + }, + "required": [ + "hearingId", + "courtCentreId", + "startDate" + ], + "additionalProperties": false +} diff --git a/listing-command/listing-command-api/src/raml/listing-command-api.raml b/listing-command/listing-command-api/src/raml/listing-command-api.raml index 68aefb4d7..6e014819f 100644 --- a/listing-command/listing-command-api/src/raml/listing-command-api.raml +++ b/listing-command/listing-command-api/src/raml/listing-command-api.raml @@ -103,6 +103,9 @@ protocols: [ HTTP, HTTPS ] (mapping): requestType: application/vnd.listing.command.vacate-trial+json name: listing.command.vacate-trial + (mapping): + requestType: application/vnd.listing.command.move-hearing-to-past-date+json + name: listing.command.move-hearing-to-past-date (mapping): requestType: application/vnd.listing.duplicate-unallocated-hearing+json name: listing.mark-unallocated-hearing-as-duplicate @@ -144,6 +147,9 @@ protocols: [ HTTP, HTTPS ] application/vnd.listing.command.vacate-trial+json: example: !include json/listing.command.vacate-trial.json schema: !include json/schema/listing.command.vacate-trial.json + application/vnd.listing.command.move-hearing-to-past-date+json: + example: !include json/listing.command.move-hearing-to-past-date.json + schema: !include json/schema/listing.command.move-hearing-to-past-date.json application/vnd.listing.duplicate-unallocated-hearing+json: !!null application/vnd.listing.next-hearings-v2+json: example: !include json/listing.list-next-hearings-v2.json diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java index bc75f8903..9c232c3d1 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java @@ -2,7 +2,7 @@ import static java.util.Collections.singletonMap; import static org.mockito.BDDMockito.given; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createCourtSchedulePermission; +import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ADMINISTRATORS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ASSOCIATE; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_CLERKS; @@ -333,9 +333,9 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() { } @Test - public void shouldAllowUserWithCourtScheduleCreatePermissionToMoveHearingToPastDate() throws JsonProcessingException { + public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToPastDate() throws JsonProcessingException { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); - given(userAndGroupProvider.hasPermission(action, createCourtSchedulePermission())).willReturn(true); + given(userAndGroupProvider.hasPermission(action, createChangeHearingToPastDatePermission())).willReturn(true); final ExecutionResults results = executeRulesWith(action); @@ -343,11 +343,12 @@ public void shouldAllowUserWithCourtScheduleCreatePermissionToMoveHearingToPastD } @Test - public void shouldNotAllowUserWithoutCourtScheduleCreatePermissionToMoveHearingToPastDate() { + public void shouldNotAllowUserWithoutChangeHearingToPastDatePermissionToMoveHearingToPastDate() { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); final ExecutionResults results = executeRulesWith(action); assertFailureOutcome(results); } + } \ No newline at end of file diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index 0d32ee286..664981e2d 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -68,6 +68,10 @@ import uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory; import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory; import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator; +import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; +import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter; import uk.gov.moj.cpp.listing.common.service.HearingSlotsService; import uk.gov.moj.cpp.listing.domain.JudicialRole; import uk.gov.moj.cpp.listing.domain.JudicialRoleType; @@ -80,10 +84,12 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import uk.gov.justice.services.messaging.JsonObjects; +import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; import javax.json.JsonReader; @@ -129,6 +135,10 @@ public class ListingCommandApiTest { private HearingSlotsService hearingSlotsService; @Mock private HearingEnrichmentOrchestrator hearingEnrichmentOrchestrator; + @Mock + private CourtSchedulerServiceAdapter courtSchedulerServiceAdapter; + @Mock + private HearingLookupService hearingLookupService; private static final Type HEARING_TYPE = Type.type() .withId(fromString("6e1bef55-7e13-4615-b3ba-8663f4438e16")) @@ -587,6 +597,177 @@ public void shouldVacateTheTrial() { verify(sender, times(1)).send(senderJsonEnvelopeCaptor.capture()); } + @Test + public void shouldRejectMoveHearingToPastDateWhenHearingIdUnknown() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn("2026-05-01"); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.empty()); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("HEARING_ID_NOT_FOUND")); + verify(sender, never()).send(any()); + } + + @Test + public void shouldMoveMagistratesHearingToPastDateEnrichWithSlotDetailsAndSend() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final UUID courtScheduleId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .add("estimatedMinutes", 30) + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final MoveHearingToPastDateResult slot = new MoveHearingToPastDateResult(courtScheduleId, + "9d324f4f-6c3b-451f-ac1e-f459db781153", startDate, "2026-05-01T09:00:00Z", "2026-05-01T17:00:00Z", 30); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30)).willReturn(slot); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter).moveHearingToPastDate(hearingId, courtCentreId, startDate, 30); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("hearingId"), is(hearingId.toString())); + assertThat(sent.getString("jurisdiction"), is("MAGISTRATES")); + assertThat(sent.getString("courtScheduleId"), is(courtScheduleId.toString())); + assertThat(sent.getString("sessionDate"), is(startDate.toString())); + assertThat(sent.getInt("durationInMinutes"), is(30)); + } + + @Test + public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2999-01-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final JsonObject body = Json.createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "Hearings can only be moved to today or an earlier date") + .build(); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) + .willThrow(new MoveHearingToPastDateException(422, body, "rejected")); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + verify(sender, never()).send(any()); + } + + @Test + public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) + .willThrow(new MoveHearingToPastDateException(404, Json.createObjectBuilder().build(), "not found")); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + assertThat(thrown.getHttpStatus(), is(404)); + verify(sender, never()).send(any()); + } + + @Test + public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.now().minusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("hearingId"), is(hearingId.toString())); + assertThat(sent.getString("jurisdiction"), is("CROWN")); + assertThat(sent.getString("startDate"), is(startDate.toString())); + assertThat(sent.containsKey("courtScheduleId"), is(false)); + } + + @Test + public void shouldRejectCrownMoveToFutureDate() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.now().plusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, never()).send(any()); + } + @Test public void shouldListCourtHearing() { diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java index 303922071..250c9ae4a 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java @@ -2,7 +2,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createCourtSchedulePermission; +import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; import static uk.gov.moj.cpp.listing.command.api.util.FileUtil.getPayload; import com.fasterxml.jackson.core.JsonProcessingException; @@ -15,9 +15,9 @@ class PermissionConstantsTest { private final ObjectMapper mapper = new ObjectMapper(); @Test - void shouldCreateSchedulePermission() throws JsonProcessingException { - JsonNode actual = mapper.readTree(createCourtSchedulePermission()); - JsonNode expected = mapper.readTree(getPayload("create-court-schedule-permission.json")); + void shouldCreateChangeHearingToPastDatePermission() throws JsonProcessingException { + JsonNode actual = mapper.readTree(createChangeHearingToPastDatePermission()); + JsonNode expected = mapper.readTree(getPayload("create-change-hearing-to-past-date-permission.json")); assertThat(actual, is(expected)); } } diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java new file mode 100644 index 000000000..cf37f25ea --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java @@ -0,0 +1,74 @@ +package uk.gov.moj.cpp.listing.command.api.mapper; + +import static javax.json.Json.createObjectBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; + +import javax.json.JsonObject; +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class MoveHearingToPastDateExceptionMapperTest { + + private MoveHearingToPastDateExceptionMapper mapper; + + @BeforeEach + void setUp() { + mapper = new MoveHearingToPastDateExceptionMapper(); + mapper.logger = LoggerFactory.getLogger(MoveHearingToPastDateExceptionMapperTest.class); + } + + @Test + void futureDate_returns422_withErrorCodeAndMessage() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "Hearings can only be moved to today or an earlier date") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected")); + + assertThat(response.getStatus(), is(422)); + final String entity = response.getEntity().toString(); + assertThat(entity, containsString("\"errorCode\":\"FUTURE_DATE_NOT_ALLOWED\"")); + assertThat(entity, containsString("\"message\":\"Hearings can only be moved to today or an earlier date\"")); + } + + @Test + void unknownHearing_returns422_withHearingIdNotFound() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "HEARING_ID_NOT_FOUND") + .add("message", "No hearing found") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected")); + + assertThat(response.getStatus(), is(422)); + assertThat(response.getEntity().toString(), containsString("\"errorCode\":\"HEARING_ID_NOT_FOUND\"")); + } + + @Test + void noSession_propagates404_withMessage() { + final JsonObject body = createObjectBuilder() + .add("message", "No court-schedule session found") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(404, body, "not found")); + + assertThat(response.getStatus(), is(404)); + assertThat(response.getEntity().toString(), containsString("\"message\":\"No court-schedule session found\"")); + } + + @Test + void nullBody_fallsBackToExceptionMessage() { + final Response response = mapper.toResponse(new MoveHearingToPastDateException(500, null, "unexpected failure")); + + assertThat(response.getStatus(), is(500)); + assertThat(response.getEntity().toString(), containsString("\"message\":\"unexpected failure\"")); + } +} diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java new file mode 100644 index 000000000..b540ac3f4 --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java @@ -0,0 +1,75 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static java.util.UUID.randomUUID; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static uk.gov.justice.services.test.utils.core.enveloper.EnveloperFactory.createEnveloper; +import static uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory.metadataWithRandomUUIDAndName; + +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.messaging.JsonEnvelope; +import uk.gov.moj.cpp.listing.query.view.HearingQueryView; + +import java.util.Optional; +import java.util.UUID; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.ws.rs.NotFoundException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HearingLookupServiceTest { + + @Spy + private final Enveloper enveloper = createEnveloper(); + + @Mock + private HearingQueryView hearingQueryView; + + @Mock + private JsonEnvelope envelope; + + @InjectMocks + private HearingLookupService hearingLookupService; + + @Test + void shouldReturnHearingWhenFound() { + final UUID hearingId = randomUUID(); + final JsonObject hearingPayload = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenReturn(JsonEnvelope.envelopeFrom(metadataWithRandomUUIDAndName().build(), hearingPayload)); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isPresent()); + assertThat(result.get().getString("jurisdictionType"), is("MAGISTRATES")); + } + + @Test + void shouldReturnEmptyWhenHearingNotFound() { + final UUID hearingId = randomUUID(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenThrow(new NotFoundException("no hearing")); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isEmpty()); + } +} diff --git a/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json new file mode 100644 index 000000000..2f33b700e --- /dev/null +++ b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json @@ -0,0 +1,6 @@ +{ +"object":"Change hearing to past date", +"action":"Link", +"key":"Change hearing to past date_Link", +"keyWithOutSource":"Change hearing to past date_Link" +} diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index b49b4ddb2..c5b031966 100644 --- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java +++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java @@ -192,6 +192,11 @@ public class ListingCommandHandler { private static final String HEARINGS = "hearings"; private static final String PROSECUTION_CASE = "prosecutionCase"; public static final String OUCODE = "oucode"; + private static final String JURISDICTION = "jurisdiction"; + private static final String START_DATE = "startDate"; + private static final String COURT_SCHEDULE_ID = "courtScheduleId"; + private static final String SESSION_DATE = "sessionDate"; + private static final String CROWN_JURISDICTION = "CROWN"; @Inject private EventSource eventSource; @@ -412,6 +417,31 @@ public void vacateTrial(final JsonEnvelope command) throws EventStreamException updateHearingEventStream(command, vacateTrialEnriched.getHearingId(), (Hearing hearing) -> hearing.vacateTrial(vacateTrialEnriched.getHearingId(), vacateTrialEnriched.getVacatedTrialReasonId())); } + @Handles("listing.command.move-hearing-to-past-date-enriched") + public void moveHearingToPastDate(final JsonEnvelope command) throws EventStreamException { + + LOGGER.info("'listing.command.move-hearing-to-past-date-enriched' received with payload {}", command.toObfuscatedDebugString()); + + final JsonObject payload = command.payloadAsJsonObject(); + final UUID hearingId = fromString(payload.getString(HEARING_ID)); + final String jurisdiction = payload.getString(JURISDICTION); + + if (CROWN_JURISDICTION.equals(jurisdiction)) { + // Listing-side-only re-date (Baris decision D1) - courtscheduler was never called for CROWN, + // so re-use the existing changeStartDate aggregate method rather than the courtschedule-slot event. + final LocalDate startDate = parse(payload.getString(START_DATE)); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); + } else { + final HearingDayCourtSchedule hearingDayCourtSchedule = HearingDayCourtSchedule.hearingDayCourtSchedule() + .withCourtScheduleId(fromString(payload.getString(COURT_SCHEDULE_ID))) + .withHearingDate(parse(payload.getString(SESSION_DATE))) + .build(); + + updateHearingEventStream(command, hearingId, + hearing -> hearing.raiseHearingDayCourtSchedulesUpdated(hearingId, List.of(hearingDayCourtSchedule))); + } + } + @Handles("listing.command.hearing-vacate-trial") public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException { LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString()); diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..b31a131ee --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,11 @@ +{ + "hearingId": "0baecac5-222b-402d-9047-84803679edae", + "jurisdiction": "MAGISTRATES", + "startDate": "2026-05-01", + "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", + "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153", + "sessionDate": "2026-05-01", + "sessionStartTime": "2026-05-01T09:00:00Z", + "sessionEndTime": "2026-05-01T17:00:00Z", + "durationInMinutes": 30 +} diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..562ab41be --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date-enriched.json", + "type": "object", + "properties": { + "hearingId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "jurisdiction": { + "type": "string", + "enum": ["CROWN", "MAGISTRATES"] + }, + "startDate": { + "type": "string", + "format": "date" + }, + "courtScheduleId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtRoomId": { + "type": "string" + }, + "sessionDate": { + "type": "string", + "format": "date" + }, + "sessionStartTime": { + "type": "string" + }, + "sessionEndTime": { + "type": "string" + }, + "durationInMinutes": { + "type": "integer" + } + }, + "required": [ + "hearingId", + "jurisdiction", + "startDate" + ], + "additionalProperties": false +} diff --git a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml index b400b48b1..988287e84 100644 --- a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml +++ b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml @@ -193,6 +193,10 @@ baseUri: message://command/handler/message/listing example: !include json/listing.command.vacate-trial-enriched.json schema: !include json/schema/listing.command.vacate-trial-enriched.json + application/vnd.listing.command.move-hearing-to-past-date-enriched+json: + example: !include json/listing.command.move-hearing-to-past-date-enriched.json + schema: !include json/schema/listing.command.move-hearing-to-past-date-enriched.json + application/vnd.listing.command.hearing-vacate-trial+json: example: !include json/listing.command.hearing-vacate-trial.json schema: !include json/schema/listing.command.hearing-vacate-trial.json diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index 80072e0b0..5c24cd8cf 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -99,6 +99,7 @@ import uk.gov.justice.listing.events.CaseEjected; import uk.gov.justice.listing.events.CaseIdentifierUpdated; import uk.gov.justice.listing.events.CasesAddedToHearing; +import uk.gov.justice.listing.events.HearingDayCourtSchedule; import uk.gov.justice.listing.events.CourtApplicationAddedToHearing; import uk.gov.justice.listing.events.CourtApplicationToBeUpdated; import uk.gov.justice.listing.events.CourtListRestricted; @@ -2549,6 +2550,40 @@ public void listingCommandHandlerShouldVacateTrial() throws Exception { verify(hearing, times(1)).vacateTrial(HEARING_ID_1, REASON); } + @Test + public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws Exception { + final UUID courtScheduleId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveHearingToPastDate(courtScheduleId, "2026-05-01"); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), any())).thenReturn(mock(Stream.class)); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), captor.capture()); + verify(hearing, never()).changeStartDate(any(), any()); + final HearingDayCourtSchedule applied = captor.getValue().get(0); + assertThat(applied.getCourtScheduleId(), is(courtScheduleId)); + assertThat(applied.getHearingDate(), is(LocalDate.parse("2026-05-01"))); + } + + @Test + public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception { + final String startDate = "2026-05-01"; + final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.changeStartDate(LocalDate.parse(startDate), HEARING_ID_1)).thenReturn(mock(Stream.class)); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + } + @Test public void listingCommandHandlerShouldHearingVacateTrial() throws Exception { final JsonEnvelope commandEnvelope = getEnvelopeForHearingVacateTrial(REASON); @@ -2739,6 +2774,19 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) { return createEnvelope("listing.command.vacate-trial-enriched", jsonReader.readObject()); } + private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"" + + sessionDate + "\",\"courtScheduleId\":\"" + courtScheduleId + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + + private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + "\"}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + private JsonEnvelope getEnvelopeForHearingVacateTrial(final UUID reason) { final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"vacatedTrialReasonId\":\"" + reason + "\"}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java new file mode 100644 index 000000000..f8fe8601c --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java @@ -0,0 +1,38 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import static uk.gov.justice.services.messaging.JsonObjects.getString; + +import javax.json.JsonObject; + +/** + * Raised when courtscheduler rejects a move-hearing-to-past-date request (422/404), or when the + * listing side rejects the request before ever calling courtscheduler (unknown hearingId, future + * date on the CROWN listing-side path). Carries the upstream HTTP status and body so the + * {@code MoveHearingToPastDateExceptionMapper} can render an equivalent response back to the + * caller. + */ +public class MoveHearingToPastDateException extends RuntimeException { + + private final int httpStatus; + private final JsonObject responseBody; + private final String errorCode; + + public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) { + super(message); + this.httpStatus = httpStatus; + this.responseBody = responseBody; + this.errorCode = responseBody == null ? null : getString(responseBody, "errorCode").orElse(null); + } + + public int getHttpStatus() { + return httpStatus; + } + + public JsonObject getResponseBody() { + return responseBody; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java new file mode 100644 index 000000000..6d35209ed --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java @@ -0,0 +1,18 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Purpose-built result of a successful {@code courtscheduler.move-hearing-to-past-date} call for + * the MAGISTRATES path. Deliberately narrow — unlike the ccsph2n-only {@code CrownFallbackResult} + * this branch does not carry any crown-fallback/search-and-book concerns, only the slot details + * needed to enrich {@code listing.command.move-hearing-to-past-date-enriched}. + */ +public record MoveHearingToPastDateResult(UUID courtScheduleId, + String courtRoomId, + LocalDate sessionDate, + String sessionStartTime, + String sessionEndTime, + Integer durationInMinutes) { +} 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 d52d95c56..b5a9fe91b 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 @@ -9,6 +9,8 @@ import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackNoSessionException; import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackResult; import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackSource; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; import uk.gov.moj.cpp.listing.domain.JudicialRole; import uk.gov.moj.cpp.listing.domain.JudicialRoleType; @@ -26,8 +28,10 @@ import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; import javax.ws.rs.core.Response; import org.apache.commons.collections.CollectionUtils; @@ -67,6 +71,9 @@ public class CourtSchedulerServiceAdapter { private static final String DRAFT = "draft"; private static final String OVERBOOKED = "overbooked"; private static final String ANY_DRAFT = "anyDraft"; + // move-hearing-to-past-date (MAGS) wire-field constants; JURISDICTION reuses the field declared above + private static final String START_DATE = "startDate"; + private static final String MAGISTRATES_JURISDICTION = "MAGISTRATES"; @Inject private HearingSlotsService hearingSlotsService; @Inject @@ -472,4 +479,49 @@ HearingIdsResponse getHearingIds(final Response response) { return new HearingIdsResponse(uuids, results, pageCount); } + + /** + * MAGISTRATES-only. Calls courtscheduler's {@code move-hearing-to-past-date} action + * synchronously. CROWN moves are handled entirely listing-side and never reach this method + * (Baris decision D1). On any non-200 response the upstream errorCode/status is surfaced via + * {@link MoveHearingToPastDateException} so the caller sends no event. + */ + public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, + final UUID courtCentreId, + final LocalDate startDate, + final Integer durationInMinutes) { + final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() + .add(HEARING_ID, hearingId.toString()) + .add(COURT_CENTRE_ID, courtCentreId.toString()) + .add(JURISDICTION, MAGISTRATES_JURISDICTION) + .add(START_DATE, startDate.toString()); + if (durationInMinutes != null) { + requestBuilder.add(DURATION_IN_MINUTES, durationInMinutes); + } + + final Response response = hearingSlotsService.moveHearingToPastDate(hearingId, requestBuilder.build()); + final int status = response.getStatus(); + final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject) + ? (JsonObject) response.getEntity() + : Json.createObjectBuilder().build(); + + if (HttpStatus.SC_OK == status) { + return parseMoveHearingToPastDateResult(body); + } + + LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", + status, hearingId, body); + throw new MoveHearingToPastDateException(status, body, + "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); + } + + private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { + return new MoveHearingToPastDateResult( + body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, + body.getString("courtRoomId", null), + body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, + body.getString("sessionStartTime", null), + body.getString("sessionEndTime", null), + body.containsKey("durationInMinutes") ? body.getInt("durationInMinutes") : null); + } } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index eabf8d77b..57e8fe218 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -37,6 +37,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.json.JsonObject; + @SuppressWarnings({"squid:S1312", "squid:S2629", "squid:S6813"}) @ApplicationScoped public class HearingSlotsService { @@ -60,6 +62,9 @@ public class HearingSlotsService { private static final String COURTSCHEDULER_EXTEND_MULTIDAY = "application/vnd.courtscheduler.extend.multiday.hearing+json"; + private static final String HEARINGS_RESOURCE = "/hearings/"; + private static final String COURTSCHEDULER_MOVE_TO_PAST_DATE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; + private static final String CJS_CPP_UID = "CJSCPPUID"; @Inject @Value(key = "courtscheduler.base.url", defaultValue = "http://localhost:8080/listingcourtscheduler-api/rest/courtscheduler") @@ -144,6 +149,40 @@ public Response crownFallbackSearchAndBook(final Map params) { return postSearchBook(COURTSCHEDULER_CROWN_SEARCH_BOOK, params); } + public Response moveHearingToPastDate(final UUID hearingId, final JsonObject payload) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date for hearing id '{}'", hearingId); + } + + try { + final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + hearingId).toString()); + httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_MOVE_TO_PAST_DATE); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); + + final StringEntity requestEntity = new StringEntity(payload.toString()); + httpPost.setEntity(requestEntity); + + final HttpResponse httpResponse = execute(httpPost); + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + final String entityBodyAsString = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date returned status {}", statusCode); + } + + return Response + .status(statusCode) + .entity(entityBodyAsString.isBlank() ? null : stringToJsonObjectConverter.convert(entityBodyAsString)) + .build(); + } catch (IOException ex) { + LOGGER.error("Exception thrown on trying to move hearing to past date", ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } + } + public void delete(final UUID hearingId) { if (LOGGER.isInfoEnabled()) { LOGGER.info("Delete HearingSlots in CourtScheduler S & L with hearing id '{}'", hearingId); diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java new file mode 100644 index 000000000..b67029436 --- /dev/null +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java @@ -0,0 +1,118 @@ +package uk.gov.moj.cpp.listing.common.service; + +import static javax.json.Json.createObjectBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.json.JsonObject; +import javax.ws.rs.core.Response; + +import org.apache.http.HttpStatus; +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) +class CourtSchedulerServiceAdapterMoveHearingToPastDateTest { + + @InjectMocks + private CourtSchedulerServiceAdapter adapter; + + @Mock + private HearingSlotsService hearingSlotsService; + + @Mock + private Response response; + + @Test + void shouldParseSlotDetailsOn200() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + final JsonObject body = createObjectBuilder() + .add("hearingId", hearingId.toString()) + .add("courtScheduleId", courtScheduleId.toString()) + .add("courtRoomId", "9d324f4f-6c3b-451f-ac1e-f459db781153") + .add("sessionDate", "2026-05-01") + .add("sessionStartTime", "2026-05-01T09:00:00Z") + .add("sessionEndTime", "2026-05-01T17:00:00Z") + .add("durationInMinutes", 30) + .add("source", "MOVE_TO_PAST_DATE") + .build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30); + + assertThat(result.courtScheduleId(), is(courtScheduleId)); + assertThat(result.courtRoomId(), is("9d324f4f-6c3b-451f-ac1e-f459db781153")); + assertThat(result.sessionDate(), is(startDate)); + assertThat(result.sessionStartTime(), is("2026-05-01T09:00:00Z")); + assertThat(result.sessionEndTime(), is("2026-05-01T17:00:00Z")); + assertThat(result.durationInMinutes(), is(30)); + } + + @Test + void shouldOmitDurationInRequestWhenNotSupplied() { + final UUID hearingId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, UUID.randomUUID(), LocalDate.parse("2026-05-01"), null); + + assertThat(result.durationInMinutes(), is(nullValue())); + } + + @Test + void shouldThrowWith422AndErrorCodeWhenFutureDate() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "must not be after today") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2999-01-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldThrowWith404WhenNoSession() { + final JsonObject body = createObjectBuilder().build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(HttpStatus.SC_NOT_FOUND)); + } +} diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java index 37b35a1d4..daf489bee 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java @@ -950,4 +950,78 @@ void buildTypedJsonBodyShouldFallbackToStringWhenDurationIsNotANumber() { assertThat(result.getString("durationInMinutes"), is("notANumber")); } + + @Test + public void shouldPostMoveHearingToPastDateSuccessfully() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + final HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.move-hearing-to-past-date+json")); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateErrorResponse() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(422); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(422)); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateIOException() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Test exception")); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())); + } + } } \ No newline at end of file diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java new file mode 100644 index 000000000..45252961a --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -0,0 +1,145 @@ +package uk.gov.moj.cpp.listing.it; + +import static java.util.UUID.randomUUID; +import static javax.ws.rs.core.Response.Status.ACCEPTED; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled; + +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.Test; + +/** + * Covers listing.command.move-hearing-to-past-date: MAGISTRATES wraps courtscheduler synchronously + * and stores the returned slot as enrichment; CROWN is listing-side-only (Baris decision D1) and + * never calls courtscheduler. Single-day only. + */ +class MoveHearingToPastDateIT extends AbstractIT { + + private static final String COURT_ROOM_ID = "731816"; + + @Test + void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final LocalDate pastDate = ItClock.today().minusDays(1); + final String courtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateCalled(moveSteps.getHearingId()); + moveSteps.verifyCourtScheduleStored(courtScheduleId); + } + + @Test + void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final String firstCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), firstCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(firstCourtScheduleId); + + final String secondCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), secondCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(secondCourtScheduleId); + } + + @Test + void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED", + "Hearings can only be moved to today or an earlier date"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(404)); + } + + @Test + void shouldRejectMoveWith422WhenHearingIdUnknown() { + // A hearing that was never listed - MoveHearingToPastDateSteps still needs SOME allocated + // hearing to obtain a courtCentreId, but we submit against a random unknown hearingId. + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final UUID unknownHearingId = randomUUID(); + + final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("HEARING_ID_NOT_FOUND")); + verifyMoveHearingToPastDateNeverCalled(unknownHearingId.toString()); + } + + @Test + void shouldRejectMoveWith400WhenMandatoryFieldMissing() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(400)); + } + + @Test + void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + moveSteps.verifyStartDateUpdated(pastDate); + } + + @Test + void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java new file mode 100644 index 000000000..74adbf7b4 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -0,0 +1,111 @@ +package uk.gov.moj.cpp.listing.steps; + +import static com.jayway.jsonpath.matchers.JsonPathMatchers.withJsonPath; +import static java.text.MessageFormat.format; +import static javax.ws.rs.core.Response.Status.OK; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.justice.services.common.http.HeaderConstants.USER_ID; +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 uk.gov.moj.cpp.listing.utils.FileUtil.getPayload; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.WireMockStubUtils.setupLoggedInUserPermissionsWithChangeHearingToPastDate; + +import uk.gov.moj.cpp.listing.it.AbstractIT; +import uk.gov.moj.cpp.listing.steps.data.HearingData; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +/** + * Steps for the listing.command.move-hearing-to-past-date wrapper endpoint. Same + * {@code POST /hearings/{hearingId}} resource as vacate-trial/extend-hearing, distinguished by + * media type {@code application/vnd.listing.command.move-hearing-to-past-date+json}. + */ +public class MoveHearingToPastDateSteps extends AbstractIT { + + private static final String LISTING_QUERY_HEARING = "listing.search.hearing"; + private static final String MEDIA_TYPE_SEARCH_HEARING = "application/vnd.listing.search.hearing+json"; + private static final String LISTING_COMMAND_MOVE = "listing.command.move-hearing-to-past-date"; + private static final String MEDIA_TYPE_MOVE = "application/vnd.listing.command.move-hearing-to-past-date+json"; + + private final String hearingId; + private final UUID courtCentreId; + + public MoveHearingToPastDateSteps(final HearingsData hearingsData) { + final HearingData hearingData = hearingsData.getHearingData().get(0); + this.hearingId = hearingData.getId().toString(); + this.courtCentreId = hearingData.getCourtCentreId(); + givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); + setupLoggedInUserPermissionsWithChangeHearingToPastDate(); + } + + public String getHearingId() { + return hearingId; + } + + public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) { + final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json") + .replace("%%HEARING_ID%%", hearingId) + .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString()) + .replace("%%START_DATE%%", date.toString()); + + return postMove(payload); + } + + public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { + final String payload = "{\"hearingId\":\"" + hearingId + "\",\"startDate\":\"" + date + "\"}"; + return postMove(hearingId, payload); + } + + /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this + * steps' own courtCentreId so only the hearingId lookup is exercised. */ + public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) { + final String payload = "{\"hearingId\":\"" + otherHearingId + "\",\"courtCentreId\":\"" + courtCentreId + + "\",\"startDate\":\"" + date + "\"}"; + return postMove(otherHearingId.toString(), payload); + } + + private Response postMove(final String payload) { + return postMove(hearingId, payload); + } + + private Response postMove(final String targetHearingId, final String payload) { + final String url = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_COMMAND_MOVE), targetHearingId)); + return restClient.postCommand(url, MEDIA_TYPE_MOVE, payload, getLoggedInHeader()); + } + + public void verifyCourtScheduleStored(final String expectedCourtScheduleId) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.hearingDays[*].courtScheduleId", hasItem(expectedCourtScheduleId)) + ))); + } + + public void verifyStartDateUpdated(final LocalDate expectedStartDate) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.startDate", is(expectedStartDate.toString())) + ))); + } +} 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 904670d95..079c887f1 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 @@ -66,6 +66,7 @@ public class CourtSchedulerServiceStub { private static final String MAGS_SEARCH_AND_BOOK_TYPE = "application/vnd.courtscheduler.mags.search.and.book+json"; /** Kept for backward-compat constant name; body of crown-fallback stub now uses CROWN_SEARCH_AND_BOOK_TYPE. */ private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = CROWN_SEARCH_AND_BOOK_TYPE; + private static final String MOVE_HEARING_TO_PAST_DATE_TYPE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json"; private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json"; public static final String COURTSCHEDULER_GET_PROVISIONAL_BOOKING_TYPE = "application/vnd.courtscheduler.get.provisional.booking+json"; @@ -1493,4 +1494,67 @@ private static void stubCourtSchedulesByIdResponse(final String body) { .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + + // --- move-hearing-to-past-date stubs (MAGISTRATES-only: CROWN never reaches courtscheduler, Baris decision D1) --- + + /** Stub a successful POST /hearings/{hearingId} move-hearing-to-past-date response. */ + public static void stubMoveHearingToPastDate(final String hearingId, + final String courtScheduleId, + final String courtRoomId, + final LocalDate sessionDate, + final int durationInMinutes) { + final String startTime = sessionDate + "T09:00:00Z"; + final String endTime = sessionDate + "T17:00:00Z"; + final String body = format( + "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," + + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + + "\"durationInMinutes\":%s}", + hearingId, courtScheduleId, courtRoomId, sessionDate, startTime, endTime, durationInMinutes); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withBody(body) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Stub a courtscheduler rejection (422 FUTURE_DATE_NOT_ALLOWED or 404 no-session) for move-hearing-to-past-date. */ + public static void stubMoveHearingToPastDateFailure(final String hearingId, + final int statusCode, + final String errorCode, + final String message) { + final StringBuilder body = new StringBuilder("{"); + if (errorCode != null) { + body.append("\"errorCode\":\"").append(errorCode).append("\","); + } + body.append("\"message\":\"").append(message).append("\"}"); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) + .willReturn(aResponse().withStatus(statusCode) + .withBody(body.toString()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing. */ + public static void verifyMoveHearingToPastDateCalled(final String hearingId) { + Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { + try { + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\""))); + return true; + } catch (VerificationException e) { + return false; + } + }); + } + + /** Regression guard for the CROWN listing-side-only path: courtscheduler must never be called. */ + public static void verifyMoveHearingToPastDateNeverCalled(final String hearingId) { + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); + } } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 3668ccd21..36fb58c3c 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -62,6 +62,28 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } + /** + * Overrides the empty-permissions catch-all ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}, + * registered in {@code AbstractIT.setUp()} at priority 1) so the "Change hearing to past date"/"Link" + * permission checked by the move-hearing-to-past-date DRL rule resolves to true. Same priority + same + * Accept-header specificity as the catch-all — WireMock resolves same-priority ties in favour of the + * most-recently-registered stub, and this is registered later (from the test's Steps constructor). + */ + public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { + stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) + .atPriority(1) + .withHeader("Accept", containing("application/vnd.usersgroups.is-logged-in-user-has-permission-for-action+json")) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withHeader("CPPID", randomUUID().toString()) + .withHeader("Content-Type", "application/json") + .withBody(String.valueOf(createObjectBuilder() + .add("groups", createArrayBuilder()) + .add("switchableRoles", createArrayBuilder()) + .add("permissions", createArrayBuilder() + .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) + .build())))); + } + public static void setupAsUnauthorisedUser(final UUID userId) { stubPingFor("usersgroups-service"); diff --git a/listing-integration-test/src/test/resources/endpoint.properties b/listing-integration-test/src/test/resources/endpoint.properties index fbe0fde74..fbfd6a9b9 100644 --- a/listing-integration-test/src/test/resources/endpoint.properties +++ b/listing-integration-test/src/test/resources/endpoint.properties @@ -46,6 +46,7 @@ listing.publishedcourtlist=listing-service/query/view/rest/listing/publishedcour listing.command.publish-court-lists-for-crown-courts=listing-service/command/api/rest/listing/publishCourtListsForCrownCourts listing.command.extend-hearing-for-hearing=listing-service/command/api/rest/listing/hearings/{0} listing.command.hearing-vacate-trial=listing-service/command/api/rest/listing/hearings/{0} +listing.command.move-hearing-to-past-date=listing-service/command/api/rest/listing/hearings/{0} listing.command.create-listing-note=listing-service/command/api/rest/listing/listing-note listing.command.edit-listing-note=listing-service/command/api/rest/listing/listing-notes/{0} listing.search.hearings.by.allocated.court-room-id.search-date=listing-service/query/api/rest/listing/hearings/?allocated={0}&courtRoomId={1}&searchDate={2} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json new file mode 100644 index 000000000..4bc623ddd --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "%%HEARING_ID%%", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +} diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json new file mode 100644 index 000000000..4bc623ddd --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "%%HEARING_ID%%", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +} From 2ea5e952db2c881ad7951f923e3ee15882f7a451 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:38:31 +0100 Subject: [PATCH 14/27] fix(SPRDT-987): stub get-logged-in-user-permissions query for hasPermission DRL path in move IT --- .../moj/cpp/listing/utils/WireMockStubUtils.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 36fb58c3c..631025dcf 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -63,22 +63,21 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } /** - * Overrides the empty-permissions catch-all ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}, - * registered in {@code AbstractIT.setUp()} at priority 1) so the "Change hearing to past date"/"Link" - * permission checked by the move-hearing-to-past-date DRL rule resolves to true. Same priority + same - * Accept-header specificity as the catch-all — WireMock resolves same-priority ties in favour of the - * most-recently-registered stub, and this is registered later (from the test's Steps constructor). + * Serves {@code UserAndGroupProvider.getUserPermissionsByUserId} (the {@code hasPermission} DRL path, + * Accept {@code application/vnd.usersgroups.get-logged-in-user-permissions+json}) so the + * "Change hearing to past date"/"Link" permission checked by the move-hearing-to-past-date rule + * resolves to true. Matches on URL alone — the empty-permissions catch-all + * ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}) only matches the + * is-logged-in-user-has-permission-for-action Accept header, so without this stub the permissions + * query gets no match and the provider receives a NULL payload. */ public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) .atPriority(1) - .withHeader("Accept", containing("application/vnd.usersgroups.is-logged-in-user-has-permission-for-action+json")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withHeader("CPPID", randomUUID().toString()) .withHeader("Content-Type", "application/json") .withBody(String.valueOf(createObjectBuilder() - .add("groups", createArrayBuilder()) - .add("switchableRoles", createArrayBuilder()) .add("permissions", createArrayBuilder() .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) .build())))); From b03567248253638772a89bdcbbd7874e9ec9114a Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:52:51 +0100 Subject: [PATCH 15/27] fix(SPRDT-987): list hearings through the real flow before moving them in MoveHearingToPastDateIT --- .../listing/it/MoveHearingToPastDateIT.java | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index 45252961a..e41a14fbb 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -8,16 +8,22 @@ import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessions; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubProvisionalBookingWithCustomParams; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled; import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; import uk.gov.moj.cpp.listing.steps.data.HearingsData; import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import javax.ws.rs.core.Response; @@ -33,10 +39,44 @@ class MoveHearingToPastDateIT extends AbstractIT { private static final String COURT_ROOM_ID = "731816"; + /** + * Lists a real hearing through the full flow (command → events → viewstore projection) and only + * returns once it is queryable — the move command's HEARING_ID_NOT_FOUND pre-check reads the + * viewstore, so moving an un-listed hearing is legitimately rejected. Mirrors VacateHearingIT: + * MAGS listing needs the provisional-booking + list-hearing-in-court-sessions stubs; CROWN + * listing never calls courtscheduler pre-Phase-2. + */ + private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction) { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(jurisdiction); + final ListCourtHearingSteps listCourtHearingSteps = new ListCourtHearingSteps(hearingsData); + + if (MAGISTRATES_JURISDICTION.equals(jurisdiction)) { + final ZonedDateTime hearingStartTime = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getHearingStartTime(); + final UUID courtCentreId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtCentreId(); + final UUID courtroomId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtRoomId(); + final String listedCourtScheduleId = randomUUID().toString(); + + final Map stubParams = new HashMap<>(); + stubParams.put("SESSION_DATE", hearingStartTime.toLocalDate().toString()); + stubParams.put("COURT_CENTRE_ID", courtCentreId.toString()); + stubParams.put("COURT_SCHEDULE_ID", listedCourtScheduleId); + stubParams.put("COURT_ROOM_ID", courtroomId.toString()); + stubParams.put("BOOKING_ID", randomUUID().toString()); + stubParams.put("HEARING_START_TIME", hearingStartTime.toString()); + stubProvisionalBookingWithCustomParams(stubParams); + stubListHearingInCourtSessions(listCourtHearingSteps.getHearingsData().getHearingData().get(0).getId().toString(), + listedCourtScheduleId, hearingStartTime); + } + + listCourtHearingSteps.whenCaseIsSubmittedForListing(); + listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED); + + return new MoveHearingToPastDateSteps(hearingsData); + } + @Test void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final String courtScheduleId = randomUUID().toString(); @@ -51,8 +91,7 @@ void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { @Test void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final String firstCourtScheduleId = randomUUID().toString(); @@ -68,8 +107,7 @@ void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { @Test void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED", "Hearings can only be moved to today or an earlier date"); @@ -82,8 +120,7 @@ void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAll @Test void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, "No court-schedule session found for the given date and court centre"); @@ -120,8 +157,7 @@ void shouldRejectMoveWith400WhenMandatoryFieldMissing() { @Test void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); @@ -133,8 +169,7 @@ void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler @Test void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1)); From ca11f8b247a3941d8f6ccf36aab90d0408a80d73 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 20:14:25 +0100 Subject: [PATCH 16/27] fix(SPRDT-987): MAGS move re-issues the hearing day on the past date (changeStartDate + assignHearingDaysV2) - the court-schedule event alone cannot re-date a day --- .../command/api/ListingCommandApi.java | 3 +- .../handler/ListingCommandHandler.java | 40 ++++++++++++++++--- ...nd.move-hearing-to-past-date-enriched.json | 1 + ...nd.move-hearing-to-past-date-enriched.json | 6 ++- .../handler/ListingCommandHandlerTest.java | 22 ++++++---- .../listing/it/MoveHearingToPastDateIT.java | 2 +- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 3321f5879..3a43894ac 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -380,7 +380,8 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { final JsonObjectBuilder enrichedBuilder = createObjectBuilder() .add(HEARING_ID, hearingId.toString()) .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType) - .add(START_DATE, startDate.toString()); + .add(START_DATE, startDate.toString()) + .add(COURT_CENTRE_ID, courtCentreId.toString()); if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index c5b031966..3992d134d 100644 --- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java +++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java @@ -196,6 +196,11 @@ public class ListingCommandHandler { private static final String START_DATE = "startDate"; private static final String COURT_SCHEDULE_ID = "courtScheduleId"; private static final String SESSION_DATE = "sessionDate"; + private static final String MOVE_COURT_CENTRE_ID = "courtCentreId"; + private static final String MOVE_COURT_ROOM_ID = "courtRoomId"; + private static final String SESSION_START_TIME = "sessionStartTime"; + private static final String SESSION_END_TIME = "sessionEndTime"; + private static final String DURATION_IN_MINUTES = "durationInMinutes"; private static final String CROWN_JURISDICTION = "CROWN"; @Inject @@ -432,13 +437,38 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream final LocalDate startDate = parse(payload.getString(START_DATE)); updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); } else { - final HearingDayCourtSchedule hearingDayCourtSchedule = HearingDayCourtSchedule.hearingDayCourtSchedule() - .withCourtScheduleId(fromString(payload.getString(COURT_SCHEDULE_ID))) - .withHearingDate(parse(payload.getString(SESSION_DATE))) + final LocalDate startDate = parse(payload.getString(START_DATE)); + final LocalDate sessionDate = parse(payload.getString(SESSION_DATE)); + final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); + final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); + final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) + : sessionDate.atStartOfDay(java.time.ZoneOffset.UTC); + final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; + final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) + ? payload.getInt(DURATION_IN_MINUTES) : null; + + // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot + // move a day to a new date. Re-issue the single day on the past date carrying the booked slot. + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() + .withHearingDate(sessionDate) + .withStartTime(dayStartTime) + .withEndTime(dayEndTime) + .withDurationMinutes(durationInMinutes) + .withSequence(1) + .withCourtScheduleId(Optional.of(courtScheduleId)) + .withCourtCentreId(courtCentreId) + .withCourtRoomId(courtRoomId) .build(); - updateHearingEventStream(command, hearingId, - hearing -> hearing.raiseHearingDayCourtSchedulesUpdated(hearingId, List.of(hearingDayCourtSchedule))); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES, emptyList()))); } } diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json index b31a131ee..dca55cc54 100644 --- a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json +++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json @@ -2,6 +2,7 @@ "hearingId": "0baecac5-222b-402d-9047-84803679edae", "jurisdiction": "MAGISTRATES", "startDate": "2026-05-01", + "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153", "sessionDate": "2026-05-01", diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json index 562ab41be..7de8fda09 100644 --- a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json +++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json @@ -14,6 +14,9 @@ "type": "string", "format": "date" }, + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, "courtScheduleId": { "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, @@ -37,7 +40,8 @@ "required": [ "hearingId", "jurisdiction", - "startDate" + "startDate", + "courtCentreId" ], "additionalProperties": false } diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index 5c24cd8cf..58e8be5db 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.any; import static org.mockito.Mockito.atLeast; @@ -2557,16 +2558,20 @@ public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); - when(hearing.raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), any())).thenReturn(mock(Stream.class)); + when(hearing.changeStartDate(eq(LocalDate.parse("2026-05-01")), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList()))).thenReturn(Stream.empty()); listingCommandHandler.moveHearingToPastDate(commandEnvelope); - final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(hearing, times(1)).raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), captor.capture()); - verify(hearing, never()).changeStartDate(any(), any()); - final HearingDayCourtSchedule applied = captor.getValue().get(0); - assertThat(applied.getCourtScheduleId(), is(courtScheduleId)); - assertThat(applied.getHearingDate(), is(LocalDate.parse("2026-05-01"))); + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).changeStartDate(LocalDate.parse("2026-05-01"), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList())); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getCourtScheduleId().orElse(null), is(courtScheduleId)); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse("2026-05-01"))); } @Test @@ -2776,7 +2781,8 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) { private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) { final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"" - + sessionDate + "\",\"courtScheduleId\":\"" + courtScheduleId + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + + sessionDate + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtScheduleId\":\"" + courtScheduleId + + "\",\"sessionDate\":\"" + sessionDate + "\"}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index e41a14fbb..8f4081b85 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -37,7 +37,7 @@ */ class MoveHearingToPastDateIT extends AbstractIT { - private static final String COURT_ROOM_ID = "731816"; + private static final String COURT_ROOM_ID = "731816c1-27ea-4711-8d92-0a1c2f3ab7de"; /** * Lists a real hearing through the full flow (command → events → viewstore projection) and only From b77ecc7ff690585f541fdb7c36f6cf29e1f3369b Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 20:50:56 +0100 Subject: [PATCH 17/27] refactor(SPRDT-987): sonar criticals - extract move enrichment helpers (S3776), reuse adapter constants (S1192), transient responseBody (S1948) --- .../command/api/ListingCommandApi.java | 67 +++++++++++-------- .../MoveHearingToPastDateException.java | 4 +- .../service/CourtSchedulerServiceAdapter.java | 4 +- 3 files changed, 43 insertions(+), 32 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 3a43894ac..fd554866f 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -385,42 +385,51 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. - if (startDate.isAfter(LocalDate.now())) { - throw new MoveHearingToPastDateException(422, - buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), - "Hearings can only be moved to today or an earlier date"); - } + rejectCrownMoveToFutureDate(startDate); } else { - final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) - ? hearing.getInt(ESTIMATED_MINUTES) : null; - - final MoveHearingToPastDateResult slot = - courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); - - if (slot.courtScheduleId() != null) { - enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); - } - if (slot.courtRoomId() != null) { - enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); - } - if (slot.sessionDate() != null) { - enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); - } - if (slot.sessionStartTime() != null) { - enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); - } - if (slot.sessionEndTime() != null) { - enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); - } - if (slot.durationInMinutes() != null) { - enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); - } + enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing); } sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED), enrichedBuilder.build())); } + private static void rejectCrownMoveToFutureDate(final LocalDate startDate) { + if (startDate.isAfter(LocalDate.now())) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), + "Hearings can only be moved to today or an earlier date"); + } + } + + private void enrichWithBookedPastDateSlot(final JsonObjectBuilder enrichedBuilder, final UUID hearingId, + final UUID courtCentreId, final LocalDate startDate, final JsonObject hearing) { + final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) + ? hearing.getInt(ESTIMATED_MINUTES) : null; + + final MoveHearingToPastDateResult slot = + courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); + + if (slot.courtScheduleId() != null) { + enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); + } + if (slot.courtRoomId() != null) { + enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); + } + if (slot.sessionDate() != null) { + enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); + } + if (slot.sessionStartTime() != null) { + enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); + } + if (slot.sessionEndTime() != null) { + enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); + } + if (slot.durationInMinutes() != null) { + enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); + } + } + private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) { return createObjectBuilder() .add(ERROR_CODE, errorCode) diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java index f8fe8601c..852a60f49 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java @@ -13,8 +13,10 @@ */ public class MoveHearingToPastDateException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int httpStatus; - private final JsonObject responseBody; + private final transient JsonObject responseBody; private final String errorCode; public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) { 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 b5a9fe91b..7ce75f2bb 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 @@ -518,10 +518,10 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { return new MoveHearingToPastDateResult( body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, - body.getString("courtRoomId", null), + body.getString(COURT_ROOM_ID, null), body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, body.getString("sessionStartTime", null), body.getString("sessionEndTime", null), - body.containsKey("durationInMinutes") ? body.getInt("durationInMinutes") : null); + body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); } } From c454f0f56cbb15b2baee891993dd2c90dd53f095 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 21:11:42 +0100 Subject: [PATCH 18/27] feat(SPRDT-987): CROWN move re-dates the hearing day too - day rebuilt from the hearing's own room/time on the past date --- .../command/api/ListingCommandApi.java | 27 ++++++++ .../command/api/ListingCommandApiTest.java | 39 +++++++++++ .../handler/ListingCommandHandler.java | 69 +++++++++++-------- .../handler/ListingCommandHandlerTest.java | 20 ++++-- .../steps/MoveHearingToPastDateSteps.java | 3 +- 5 files changed, 123 insertions(+), 35 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index fd554866f..45d602822 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -88,6 +88,9 @@ public class ListingCommandApi { private static final String SESSION_START_TIME = "sessionStartTime"; private static final String SESSION_END_TIME = "sessionEndTime"; private static final String DURATION_IN_MINUTES = "durationInMinutes"; + private static final String HEARING_DAYS = "hearingDays"; + private static final String DAY_START_TIME = "startTime"; + private static final String DAY_DURATION_MINUTES = "durationMinutes"; private static final String ERROR_CODE = "errorCode"; private static final String MESSAGE = "message"; public static final String HEARING_ID_NOT_FOUND = "HEARING_ID_NOT_FOUND"; @@ -386,6 +389,7 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. rejectCrownMoveToFutureDate(startDate); + enrichWithExistingDayDetails(enrichedBuilder, hearing, startDate); } else { enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing); } @@ -394,6 +398,29 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { enrichedBuilder.build())); } + /** + * CROWN moves never call courtscheduler, so the re-dated hearing day is rebuilt from the + * hearing's own current first sitting day — same room and time-of-day, on the new past date. + */ + private static void enrichWithExistingDayDetails(final JsonObjectBuilder enrichedBuilder, final JsonObject hearing, final LocalDate startDate) { + final JsonArray hearingDays = hearing.containsKey(HEARING_DAYS) ? hearing.getJsonArray(HEARING_DAYS) : null; + if (hearingDays == null || hearingDays.isEmpty()) { + return; + } + final JsonObject day = hearingDays.getJsonObject(0); + enrichedBuilder.add(SESSION_DATE, startDate.toString()); + if (day.containsKey(COURT_ROOM_ID) && !day.isNull(COURT_ROOM_ID)) { + enrichedBuilder.add(COURT_ROOM_ID, day.getString(COURT_ROOM_ID)); + } + if (day.containsKey(DAY_START_TIME) && !day.isNull(DAY_START_TIME)) { + enrichedBuilder.add(SESSION_START_TIME, + java.time.ZonedDateTime.parse(day.getString(DAY_START_TIME)).with(startDate).toString()); + } + if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) { + enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES)); + } + } + private static void rejectCrownMoveToFutureDate(final LocalDate startDate) { if (startDate.isAfter(LocalDate.now())) { throw new MoveHearingToPastDateException(422, diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index 664981e2d..c601b53a1 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -742,6 +742,45 @@ public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtSc assertThat(sent.containsKey("courtScheduleId"), is(false)); } + @Test + public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final UUID crownRoomId = randomUUID(); + final LocalDate startDate = LocalDate.now().minusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .add("hearingDays", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("hearingDate", LocalDate.now().plusDays(3).toString()) + .add("startTime", LocalDate.now().plusDays(3) + "T10:30:00Z") + .add("durationMinutes", 45) + .add("courtRoomId", crownRoomId.toString()))) + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("sessionDate"), is(startDate.toString())); + assertThat(sent.getString("courtRoomId"), is(crownRoomId.toString())); + assertThat(sent.getString("sessionStartTime"), is(startDate + "T10:30Z")); + assertThat(sent.getInt("durationInMinutes"), is(45)); + assertThat(sent.containsKey("courtScheduleId"), is(false)); + } + @Test public void shouldRejectCrownMoveToFutureDate() { final UUID hearingId = randomUUID(); diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index 3992d134d..04dee7ff2 100644 --- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java +++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java @@ -430,41 +430,23 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream final JsonObject payload = command.payloadAsJsonObject(); final UUID hearingId = fromString(payload.getString(HEARING_ID)); final String jurisdiction = payload.getString(JURISDICTION); + final LocalDate startDate = parse(payload.getString(START_DATE)); + // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot + // move a day to a new date. Both paths re-issue the single day on the past date instead: + // MAGS carries the slot booked by courtscheduler, CROWN carries the hearing's own existing + // room/time (enriched by command-api from its current first day - courtscheduler is never + // called for CROWN before Phase 2, Baris decision D1). if (CROWN_JURISDICTION.equals(jurisdiction)) { - // Listing-side-only re-date (Baris decision D1) - courtscheduler was never called for CROWN, - // so re-use the existing changeStartDate aggregate method rather than the courtschedule-slot event. - final LocalDate startDate = parse(payload.getString(START_DATE)); - updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, startDate, Optional.empty()); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.CROWN, emptyList()))); } else { - final LocalDate startDate = parse(payload.getString(START_DATE)); final LocalDate sessionDate = parse(payload.getString(SESSION_DATE)); final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); - final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) - ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); - final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) - ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); - final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) - : sessionDate.atStartOfDay(java.time.ZoneOffset.UTC); - final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; - final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) - ? payload.getInt(DURATION_IN_MINUTES) : null; - - // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot - // move a day to a new date. Re-issue the single day on the past date carrying the booked slot. - final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() - .withHearingDate(sessionDate) - .withStartTime(dayStartTime) - .withEndTime(dayEndTime) - .withDurationMinutes(durationInMinutes) - .withSequence(1) - .withCourtScheduleId(Optional.of(courtScheduleId)) - .withCourtCentreId(courtCentreId) - .withCourtRoomId(courtRoomId) - .build(); - + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, sessionDate, Optional.of(courtScheduleId)); updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( hearing.changeStartDate(startDate, hearingId), hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, @@ -472,6 +454,33 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream } } + private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(final JsonObject payload, + final LocalDate dayDate, + final Optional courtScheduleId) { + final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); + final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) + : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); + final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; + final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) + ? payload.getInt(DURATION_IN_MINUTES) : null; + + return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() + .withHearingDate(dayDate) + .withStartTime(dayStartTime) + .withEndTime(dayEndTime) + .withDurationMinutes(durationInMinutes) + .withSequence(1) + .withCourtScheduleId(courtScheduleId) + .withCourtCentreId(courtCentreId) + .withCourtRoomId(courtRoomId) + .build(); + } + @Handles("listing.command.hearing-vacate-trial") public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException { LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString()); diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index 58e8be5db..19f7c1494 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -2577,16 +2577,26 @@ public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws @Test public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception { final String startDate = "2026-05-01"; - final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate); + final UUID crownRoomId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate, crownRoomId); when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); - when(hearing.changeStartDate(LocalDate.parse(startDate), HEARING_ID_1)).thenReturn(mock(Stream.class)); + when(hearing.changeStartDate(eq(LocalDate.parse(startDate)), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList()))).thenReturn(Stream.empty()); listingCommandHandler.moveHearingToPastDate(commandEnvelope); + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList())); verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse(startDate))); + assertThat(movedDay.getCourtScheduleId().isPresent(), is(false)); + assertThat(movedDay.getCourtRoomId().orElse(null), is(crownRoomId)); } @Test @@ -2787,8 +2797,10 @@ private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtSchedul return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } - private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate) { - final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + "\"}"; + private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate, final UUID courtRoomId) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtRoomId\":\"" + courtRoomId + + "\",\"sessionDate\":\"" + startDate + "\",\"sessionStartTime\":\"" + startDate + "T10:00:00Z\",\"durationInMinutes\":25}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index 74adbf7b4..e2ce84aeb 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -105,7 +105,8 @@ public void verifyStartDateUpdated(final LocalDate expectedStartDate) { status().is(OK), payload().isJson(org.hamcrest.CoreMatchers.allOf( withJsonPath("$.id", is(hearingId)), - withJsonPath("$.startDate", is(expectedStartDate.toString())) + withJsonPath("$.startDate", is(expectedStartDate.toString())), + withJsonPath("$.hearingDays[0].hearingDate", is(expectedStartDate.toString())) ))); } } From 73a8c7d28046655f67ef944682a774e53859f7a8 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 21:26:43 +0100 Subject: [PATCH 19/27] fix(SPRDT-987): always compute endTime on the moved day (event schema requires it); carry existing endTime for CROWN --- .../cpp/listing/command/api/ListingCommandApi.java | 5 +++++ .../command/handler/ListingCommandHandler.java | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 45d602822..91ecc9799 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -90,6 +90,7 @@ public class ListingCommandApi { private static final String DURATION_IN_MINUTES = "durationInMinutes"; private static final String HEARING_DAYS = "hearingDays"; private static final String DAY_START_TIME = "startTime"; + private static final String DAY_END_TIME = "endTime"; private static final String DAY_DURATION_MINUTES = "durationMinutes"; private static final String ERROR_CODE = "errorCode"; private static final String MESSAGE = "message"; @@ -416,6 +417,10 @@ private static void enrichWithExistingDayDetails(final JsonObjectBuilder enriche enrichedBuilder.add(SESSION_START_TIME, java.time.ZonedDateTime.parse(day.getString(DAY_START_TIME)).with(startDate).toString()); } + if (day.containsKey(DAY_END_TIME) && !day.isNull(DAY_END_TIME)) { + enrichedBuilder.add(SESSION_END_TIME, + java.time.ZonedDateTime.parse(day.getString(DAY_END_TIME)).with(startDate).toString()); + } if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) { enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES)); } diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index 04dee7ff2..aab8c1cb6 100644 --- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java +++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java @@ -464,10 +464,18 @@ private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(fin final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); - final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) ? payload.getInt(DURATION_IN_MINUTES) : null; + // hearing-days-changed-for-hearing requires endTime on every day; the normal listing flows + // always compute it as startTime + duration, so mirror that when the payload has no end time. + final ZonedDateTime dayEndTime; + if (payload.containsKey(SESSION_END_TIME)) { + dayEndTime = ZonedDateTime.parse(payload.getString(SESSION_END_TIME)); + } else if (durationInMinutes != null) { + dayEndTime = dayStartTime.plusMinutes(durationInMinutes); + } else { + dayEndTime = dayStartTime; + } return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() .withHearingDate(dayDate) From 87cbc9d3d1d0e41950327204e51946bc37f0dc86 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 01:39:46 +0100 Subject: [PATCH 20/27] it fix --- .../uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index 8f4081b85..ee65c735a 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -5,6 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static uk.gov.moj.cpp.listing.helper.SearchHearingHelper.pollUntilHearingIsPresent; import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; @@ -70,6 +71,12 @@ private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction listCourtHearingSteps.whenCaseIsSubmittedForListing(); listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED); + // verifyHearingListedFromAPI's indefinite json-path filters have no result matcher, so + // they match vacuously against an empty hearings list - it can return before THIS hearing + // is projected. Poll on the hearing id (hasSize(1)) so the move command's viewstore + // pre-check cannot race the hearing-listed projection and 422 with HEARING_ID_NOT_FOUND. + pollUntilHearingIsPresent(hearingsData.getHearingData().get(0).getCourtCentreId().toString(), + ALLOCATED, getLoggedInUser().toString(), hearingsData.getHearingData().get(0).getId().toString()); return new MoveHearingToPastDateSteps(hearingsData); } From 7a3ea5e6c2aaa12dc7bd9038b04f146ca30f9657 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 03:12:25 +0100 Subject: [PATCH 21/27] fix(SPRDT-987): no-session surfaced as 422 NO_SESSION_FOUND (legacy 404 normalised); hearingId dropped from command body (path param only) --- ...ing.command.move-hearing-to-past-date.json | 1 - ...ing.command.move-hearing-to-past-date.json | 4 -- .../command/api/ListingCommandApiTest.java | 11 +++-- .../service/CourtSchedulerServiceAdapter.java | 19 +++++++- ...rviceAdapterMoveHearingToPastDateTest.java | 48 ++++++++++++++++++- .../listing/it/MoveHearingToPastDateIT.java | 20 +++++++- .../steps/MoveHearingToPastDateSteps.java | 9 ++-- .../utils/CourtSchedulerServiceStub.java | 11 ++--- .../move-hearing-to-past-date.json | 1 - .../move-hearing-to-past-date.json | 1 - 10 files changed, 99 insertions(+), 26 deletions(-) diff --git a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json index 1f6e0d5ff..ea5235ca8 100644 --- a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json +++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "0baecac5-222b-402d-9047-84803679edae", "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", "startDate": "2026-05-01" } diff --git a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json index d77eddfc5..0327ea7fd 100644 --- a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json +++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json @@ -3,9 +3,6 @@ "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date.json", "type": "object", "properties": { - "hearingId": { - "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" - }, "courtCentreId": { "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, @@ -15,7 +12,6 @@ } }, "required": [ - "hearingId", "courtCentreId", "startDate" ], diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index c601b53a1..5fc0d11f2 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -686,7 +686,7 @@ public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() { } @Test - public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { + public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); final LocalDate startDate = LocalDate.parse("2026-05-01"); @@ -702,12 +702,17 @@ public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { .build(); given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + final JsonObject noSessionBody = Json.createObjectBuilder() + .add("errorCode", "NO_SESSION_FOUND") + .add("message", "No court-schedule session found") + .build(); given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) - .willThrow(new MoveHearingToPastDateException(404, Json.createObjectBuilder().build(), "not found")); + .willThrow(new MoveHearingToPastDateException(422, noSessionBody, "no session")); final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); - assertThat(thrown.getHttpStatus(), is(404)); + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("NO_SESSION_FOUND")); verify(sender, never()).send(any()); } 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 7ce75f2bb..b17df3a77 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 @@ -74,6 +74,10 @@ public class CourtSchedulerServiceAdapter { // move-hearing-to-past-date (MAGS) wire-field constants; JURISDICTION reuses the field declared above private static final String START_DATE = "startDate"; private static final String MAGISTRATES_JURISDICTION = "MAGISTRATES"; + // no-session normalisation (422 NO_SESSION_FOUND) constants + public static final String NO_SESSION_FOUND = "NO_SESSION_FOUND"; + private static final String ERROR_CODE = "errorCode"; + private static final String MESSAGE = "message"; @Inject private HearingSlotsService hearingSlotsService; @Inject @@ -490,8 +494,8 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, final UUID courtCentreId, final LocalDate startDate, final Integer durationInMinutes) { + // hearingId travels only in the URL path; courtscheduler's REST adapter injects it final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() - .add(HEARING_ID, hearingId.toString()) .add(COURT_CENTRE_ID, courtCentreId.toString()) .add(JURISDICTION, MAGISTRATES_JURISDICTION) .add(START_DATE, startDate.toString()); @@ -511,6 +515,19 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", status, hearingId, body); + + if (HttpStatus.SC_NOT_FOUND == status) { + // older courtscheduler releases signal no-session as a bare 404 - normalise to the + // 422 NO_SESSION_FOUND contract so callers see a single failure shape + final JsonObject noSessionBody = Json.createObjectBuilder() + .add(ERROR_CODE, NO_SESSION_FOUND) + .add(MESSAGE, body.getString(MESSAGE, + "No court-schedule session found for hearingId " + hearingId + " on " + startDate)) + .build(); + throw new MoveHearingToPastDateException(HttpStatus.SC_UNPROCESSABLE_ENTITY, noSessionBody, + "moveHearingToPastDate found no session for hearingId " + hearingId); + } + throw new MoveHearingToPastDateException(status, body, "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); } diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java index b67029436..94f6b787e 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; @@ -21,6 +22,7 @@ import org.apache.http.HttpStatus; 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; @@ -103,7 +105,25 @@ void shouldThrowWith422AndErrorCodeWhenFutureDate() { } @Test - void shouldThrowWith404WhenNoSession() { + void shouldThrowWith422NoSessionFoundWhenCourtschedulerReturns422() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "NO_SESSION_FOUND") + .add("message", "No session available") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNormaliseLegacy404ToA422NoSessionFound() { final JsonObject body = createObjectBuilder().build(); when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); when(response.hasEntity()).thenReturn(true); @@ -113,6 +133,30 @@ void shouldThrowWith404WhenNoSession() { final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); - assertThat(ex.getHttpStatus(), is(HttpStatus.SC_NOT_FOUND)); + assertThat(ex.getHttpStatus(), is(HttpStatus.SC_UNPROCESSABLE_ENTITY)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNotSendHearingIdInRequestBody() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + adapter.moveHearingToPastDate(hearingId, courtCentreId, LocalDate.parse("2026-05-01"), 30); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(JsonObject.class); + verify(hearingSlotsService).moveHearingToPastDate(eq(hearingId), requestCaptor.capture()); + final JsonObject request = requestCaptor.getValue(); + assertThat(request.containsKey("hearingId"), is(false)); + assertThat(request.getString("courtCentreId"), is(courtCentreId.toString())); + assertThat(request.getString("jurisdiction"), is("MAGISTRATES")); + assertThat(request.getString("startDate"), is("2026-05-01")); + assertThat(request.getInt("durationInMinutes"), is(30)); } } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index ee65c735a..0e504871c 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -126,7 +126,22 @@ void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAll } @Test - void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { + void shouldRejectMagistratesMoveWith422WhenNoCourtScheduleSessionExists() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "NO_SESSION_FOUND", + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); + } + + /** Older courtscheduler releases signalled no-session as a bare 404 - the listing adapter + * normalises that to the 422 NO_SESSION_FOUND contract. */ + @Test + void shouldNormaliseLegacyCourtscheduler404ToA422NoSessionFound() { final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, @@ -134,7 +149,8 @@ void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); - assertThat(response.getStatus(), is(404)); + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); } @Test diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index e2ce84aeb..bb1a81efc 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -53,7 +53,6 @@ public String getHearingId() { public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) { final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json") - .replace("%%HEARING_ID%%", hearingId) .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString()) .replace("%%START_DATE%%", date.toString()); @@ -61,15 +60,15 @@ public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final } public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { - final String payload = "{\"hearingId\":\"" + hearingId + "\",\"startDate\":\"" + date + "\"}"; + final String payload = "{\"startDate\":\"" + date + "\"}"; return postMove(hearingId, payload); } /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this - * steps' own courtCentreId so only the hearingId lookup is exercised. */ + * steps' own courtCentreId so only the hearingId lookup is exercised. The target hearing is + * identified purely by the URL path - hearingId is not part of the body. */ public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) { - final String payload = "{\"hearingId\":\"" + otherHearingId + "\",\"courtCentreId\":\"" + courtCentreId - + "\",\"startDate\":\"" + date + "\"}"; + final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startDate\":\"" + date + "\"}"; return postMove(otherHearingId.toString(), payload); } 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 079c887f1..95e744f6f 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 @@ -1513,13 +1513,13 @@ public static void stubMoveHearingToPastDate(final String hearingId, stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub a courtscheduler rejection (422 FUTURE_DATE_NOT_ALLOWED or 404 no-session) for move-hearing-to-past-date. */ + /** Stub a courtscheduler rejection (e.g. 422 FUTURE_DATE_NOT_ALLOWED / NO_SESSION_FOUND, or a + * legacy 404 no-session) for move-hearing-to-past-date. */ public static void stubMoveHearingToPastDateFailure(final String hearingId, final int statusCode, final String errorCode, @@ -1532,19 +1532,18 @@ public static void stubMoveHearingToPastDateFailure(final String hearingId, stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(statusCode) .withBody(body.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing. */ + /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing + * (matched on the URL path - hearingId no longer travels in the request body). */ public static void verifyMoveHearingToPastDateCalled(final String hearingId) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { WireMock.verify(WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\""))); + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); return true; } catch (VerificationException e) { return false; diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json index 4bc623ddd..ab58d1d4f 100644 --- a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json +++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "%%HEARING_ID%%", "courtCentreId": "%%COURT_CENTRE_ID%%", "startDate": "%%START_DATE%%" } diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json index 4bc623ddd..ab58d1d4f 100644 --- a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json +++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "%%HEARING_ID%%", "courtCentreId": "%%COURT_CENTRE_ID%%", "startDate": "%%START_DATE%%" } From 0d57f1cfd66a11ee247565d50140dc9131db3913 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 11:34:12 +0100 Subject: [PATCH 22/27] fix(SPRDT-987): authorise move-hearing-to-past-date by the same groups as update-hearing-for-listing - drops the unseeded 'Change hearing to past date'/Link permission (PermissionConstants removed, move IT uses the standard group stub) --- .../accesscontrol/PermissionConstants.java | 32 ------------------- .../api/accesscontrol/listing-command-api.drl | 3 +- .../command/api/ListingAccessControlTest.java | 10 +++--- .../PermissionConstantsTest.java | 23 ------------- .../steps/MoveHearingToPastDateSteps.java | 2 -- .../cpp/listing/utils/WireMockStubUtils.java | 21 ------------ 6 files changed, 6 insertions(+), 85 deletions(-) delete mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java delete mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java deleted file mode 100644 index 97bb9ea37..000000000 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java +++ /dev/null @@ -1,32 +0,0 @@ -package uk.gov.moj.cpp.listing.command.api.accesscontrol; - -import static uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission.builder; - -import uk.gov.justice.services.common.converter.jackson.ObjectMapperProducer; -import uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -@JsonPropertyOrder({"object", "action", "key", "keyWithOutSource"}) -public final class PermissionConstants { - - private static final ObjectMapper objectMapper = new ObjectMapperProducer().objectMapper(); - - private static final String CHANGE_HEARING_TO_PAST_DATE_OBJECT = "Change hearing to past date"; - private static final String LINK_ACTION = "Link"; - - private PermissionConstants() { - } - - public static String createChangeHearingToPastDatePermission() throws JsonProcessingException { - final ExpectedPermission expectedPermission = builder() - .withObject(CHANGE_HEARING_TO_PAST_DATE_OBJECT) - .withAction(LINK_ACTION) - .build(); - - return objectMapper.writeValueAsString(expectedPermission); - } - -} diff --git a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl index 6ddd8d482..048581b06 100644 --- a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl +++ b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl @@ -3,7 +3,6 @@ package uk.gov.moj.cpp.listing.command.api.accesscontrol; import uk.gov.moj.cpp.accesscontrol.drools.Outcome; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.listing.domain.RuleConstants; -import uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants; import java.util.Arrays; global uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider userAndGroupProvider; @@ -253,7 +252,7 @@ rule "Command - API - Action - listing.command.move-hearing-to-past-date" when $outcome: Outcome(); $action: Action(name == "listing.command.move-hearing-to-past-date"); - eval(userAndGroupProvider.hasPermission($action, PermissionConstants.createChangeHearingToPastDatePermission())); + eval(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups($action, RuleConstants.LISTING_OFFICERS, RuleConstants.CROWN_COURT_ADMIN, RuleConstants.COURT_ADMINISTRATORS, RuleConstants.COURT_CLERKS, RuleConstants.LEGAL_ADVISERS, RuleConstants.COURT_ASSOCIATE)); then $outcome.setSuccess(true); end diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java index 9c232c3d1..6fb85d3f3 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java @@ -2,7 +2,6 @@ import static java.util.Collections.singletonMap; import static org.mockito.BDDMockito.given; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ADMINISTRATORS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ASSOCIATE; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_CLERKS; @@ -18,7 +17,6 @@ import static uk.gov.moj.cpp.listing.domain.RuleConstants.SYSTEM_USERS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.YOTS; -import com.fasterxml.jackson.core.JsonProcessingException; import uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.accesscontrol.test.utils.BaseDroolsAccessControlTest; @@ -333,9 +331,11 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() { } @Test - public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToPastDate() throws JsonProcessingException { + public void shouldAllowAuthorisedUserToMoveHearingToPastDate() { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); - given(userAndGroupProvider.hasPermission(action, createChangeHearingToPastDatePermission())).willReturn(true); + given(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups(action, LISTING_OFFICERS, + CROWN_COURT_ADMIN, COURT_ADMINISTRATORS, COURT_CLERKS, LEGAL_ADVISERS, COURT_ASSOCIATE)) + .willReturn(true); final ExecutionResults results = executeRulesWith(action); @@ -343,7 +343,7 @@ public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToP } @Test - public void shouldNotAllowUserWithoutChangeHearingToPastDatePermissionToMoveHearingToPastDate() { + public void shouldNotAllowUnauthorisedUserToMoveHearingToPastDate() { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); final ExecutionResults results = executeRulesWith(action); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java deleted file mode 100644 index 250c9ae4a..000000000 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package uk.gov.moj.cpp.listing.command.api.accesscontrol; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; -import static uk.gov.moj.cpp.listing.command.api.util.FileUtil.getPayload; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -class PermissionConstantsTest { - - private final ObjectMapper mapper = new ObjectMapper(); - - @Test - void shouldCreateChangeHearingToPastDatePermission() throws JsonProcessingException { - JsonNode actual = mapper.readTree(createChangeHearingToPastDatePermission()); - JsonNode expected = mapper.readTree(getPayload("create-change-hearing-to-past-date-permission.json")); - assertThat(actual, is(expected)); - } -} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index bb1a81efc..4751a6baf 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -13,7 +13,6 @@ import static uk.gov.moj.cpp.listing.utils.FileUtil.getPayload; import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; -import static uk.gov.moj.cpp.listing.utils.WireMockStubUtils.setupLoggedInUserPermissionsWithChangeHearingToPastDate; import uk.gov.moj.cpp.listing.it.AbstractIT; import uk.gov.moj.cpp.listing.steps.data.HearingData; @@ -44,7 +43,6 @@ public MoveHearingToPastDateSteps(final HearingsData hearingsData) { this.hearingId = hearingData.getId().toString(); this.courtCentreId = hearingData.getCourtCentreId(); givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); - setupLoggedInUserPermissionsWithChangeHearingToPastDate(); } public String getHearingId() { diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 631025dcf..3668ccd21 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -62,27 +62,6 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } - /** - * Serves {@code UserAndGroupProvider.getUserPermissionsByUserId} (the {@code hasPermission} DRL path, - * Accept {@code application/vnd.usersgroups.get-logged-in-user-permissions+json}) so the - * "Change hearing to past date"/"Link" permission checked by the move-hearing-to-past-date rule - * resolves to true. Matches on URL alone — the empty-permissions catch-all - * ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}) only matches the - * is-logged-in-user-has-permission-for-action Accept header, so without this stub the permissions - * query gets no match and the provider receives a NULL payload. - */ - public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { - stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) - .atPriority(1) - .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withHeader("CPPID", randomUUID().toString()) - .withHeader("Content-Type", "application/json") - .withBody(String.valueOf(createObjectBuilder() - .add("permissions", createArrayBuilder() - .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) - .build())))); - } - public static void setupAsUnauthorisedUser(final UUID userId) { stubPingFor("usersgroups-service"); From 1f85d6fa458a9958ff37732b06bb34c38d9b860c Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 23:00:46 +0100 Subject: [PATCH 23/27] fix(SPRDT-987): reconcile move-hearing-to-past-date onto the SPRDT-1011 contract line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picking the pasthearings feature (built on the release snapshot) onto dev/SPRDT-1011-listing-callers (reshaped Phase-1 courtscheduler contract) left a few merge artefacts that git could not flag and that only exist on the merged result: - HearingSlotsService: both lines declared HEARINGS_RESOURCE with different values ("/hearings" vs "/hearings/") at different offsets, so the merge kept both -> duplicate field. Unified on the SPRDT-1011 convention ("/hearings") and made the move call append "/" + hearingId like every other call site. - HearingSlotsService: restored the java.net.URL import that SPRDT-1011 dropped when it migrated its own call sites to URIBuilder (the move method uses new URL). - CourtSchedulerServiceAdapter.parseMoveHearingToPastDateResult: reuse the existing COURT_SCHEDULE_ID / SESSION_DATE / SESSION_START_TIME / SESSION_END_TIME constants instead of duplicating the string literals (Sonar S1192) — those values are already declared as constants on the SPRDT-1011 line. Whole reactor compiles (main + test sources); 248 ITs pass. --- .../common/service/CourtSchedulerServiceAdapter.java | 8 ++++---- .../cpp/listing/common/service/HearingSlotsService.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 b17df3a77..a227d5639 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 @@ -534,11 +534,11 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { return new MoveHearingToPastDateResult( - body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, + body.containsKey(COURT_SCHEDULE_ID) ? UUID.fromString(body.getString(COURT_SCHEDULE_ID)) : null, body.getString(COURT_ROOM_ID, null), - body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, - body.getString("sessionStartTime", null), - body.getString("sessionEndTime", null), + body.containsKey(SESSION_DATE) ? LocalDate.parse(body.getString(SESSION_DATE)) : null, + body.getString(SESSION_START_TIME, null), + body.getString(SESSION_END_TIME, null), body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); } } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index 57e8fe218..e8d0d266b 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.net.URISyntaxException; +import java.net.URL; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -62,7 +63,6 @@ public class HearingSlotsService { private static final String COURTSCHEDULER_EXTEND_MULTIDAY = "application/vnd.courtscheduler.extend.multiday.hearing+json"; - private static final String HEARINGS_RESOURCE = "/hearings/"; private static final String COURTSCHEDULER_MOVE_TO_PAST_DATE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; private static final String CJS_CPP_UID = "CJSCPPUID"; @@ -155,7 +155,7 @@ public Response moveHearingToPastDate(final UUID hearingId, final JsonObject pay } try { - final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + hearingId).toString()); + final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + "/" + hearingId).toString()); httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_MOVE_TO_PAST_DATE); httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); From c95cf7f49648b0d85e0082a91cb9b72de13200fa Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Sat, 4 Jul 2026 01:11:35 +0100 Subject: [PATCH 24/27] fix(SPRDT-1011): drop hearingId from crown/mags search-and-book body (path-only) The courtscheduler crown.search.and.book / mags.search.and.book request schemas were reshaped to additionalProperties:false with hearingId removed (SPRDT-1089 / team-pasthearings SPRDT-985 contract). hearingId now travels only in the /hearings/{hearingId} path and is injected by the courtscheduler REST adapter. HearingSlotsService.postSearchBook still built the body from ALL params, including hearingId, so every CROWN search-and-book (list-new-hearing, update-hearing-for- listing, MCC, adjournment) was rejected 400 by the strict schema -> allocation aborted (CrownFallbackInvalidRequestException) -> CrownScheduledListingIT failed. Exclude hearingId from the search-and-book body, matching the move-hearing-to-past- date caller which already follows the reshaped contract. Adds a regression guard. SPRDT-1011 vs SPRDT-1089 contract skew fix. --- .../common/service/HearingSlotsService.java | 18 +++++++--- .../service/HearingSlotsServiceTest.java | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index e8d0d266b..0306ecd09 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; +import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -46,6 +47,7 @@ public class HearingSlotsService { private static final Logger LOGGER = LoggerFactory.getLogger(HearingSlotsService.class); public static final String HEARING_DATE = "hearingDate"; + private static final String HEARING_ID = "hearingId"; private static final String HEARING_RESOURCE = "/hearingslots"; private static final String SESSIONS_RESOURCE = "/sessions"; @@ -88,7 +90,7 @@ public Response extendMultiDayHearing(final JsonObject payload) { if (payload == null || payload.isEmpty()) { throw new DataValidationException("Payload for %s is null or empty ....".formatted(COURTSCHEDULER_EXTEND_MULTIDAY)); } - final String hearingId = payload.getString("hearingId"); + final String hearingId = payload.getString(HEARING_ID); return patch(HEARINGS_RESOURCE + "/" + hearingId, COURTSCHEDULER_EXTEND_MULTIDAY, payload); } @@ -324,19 +326,27 @@ private Response patch(final String urlPath, final String contentTypeHeader, fin /** * Posts a search-and-book request to /hearings/{hearingId} with a typed JSON body. - * Extracts "hearingId" from params map for the path; builds remaining params as a JSON body, + * Extracts "hearingId" from params map for the path; builds the remaining params as a JSON body, * converting numeric fields (durationInMinutes) to numbers and boolean fields (isPolice) to booleans. + * + *

hearingId travels ONLY in the {@code /hearings/{hearingId}} path — the courtscheduler + * crown/mags search-and-book request schemas are {@code additionalProperties:false} and no longer + * carry hearingId, so it MUST be excluded from the body (courtscheduler's REST adapter injects it + * from the path). Leaving it in the body triggers a 400 schema-validation rejection. This mirrors + * {@link #moveHearingToPastDate} and {@link CourtSchedulerServiceAdapter#moveHearingToPastDate}.

*/ private Response postSearchBook(final String contentTypeHeader, final Map params) { if (params == null) { throw new DataValidationException("Params for %s is null ....".formatted(contentTypeHeader)); } - final String hearingId = params.get("hearingId"); + final String hearingId = params.get(HEARING_ID); if (hearingId == null || hearingId.isBlank()) { throw new DataValidationException("hearingId missing from params for %s".formatted(contentTypeHeader)); } - final JsonObject payload = buildTypedJsonBody(params); + final Map bodyParams = new HashMap<>(params); + bodyParams.remove(HEARING_ID); + final JsonObject payload = buildTypedJsonBody(bodyParams); if (LOGGER.isInfoEnabled()) { LOGGER.info("{} POST /hearings/{} in CourtScheduler S & L with payload '{}'", contentTypeHeader, hearingId, payload); diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java index daf489bee..5e295d46f 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java @@ -907,6 +907,40 @@ void postSearchBookCrownShouldUseHearingIdInPath() throws Exception { } } + @Test + void postSearchBookShouldNotSendHearingIdInBody() throws Exception { + // Regression guard: the courtscheduler crown/mags search-and-book schemas are + // additionalProperties:false and no longer carry hearingId — it travels in the + // /hearings/{hearingId} path only. Sending it in the body triggers a 400 schema + // rejection that broke CrownScheduledListingIT (SPRDT-1011 vs SPRDT-1089 contract skew). + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("courtCentreId", "b21a7d44-3e0c-4f6a-8b2d-1c9e5f7a3d20"); + params.put("hearingDate", "2026-07-06"); + params.put("durationInMinutes", "720"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + hearingSlotsService.multiDaySearchAndBook(params); + + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + // hearingId identifies the hearing via the path... + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + // ...and must NOT appear in the request body. + final String body = org.apache.http.util.EntityUtils.toString(capturedPost.getEntity()); + assertThat("body must not carry hearingId", body.contains("hearingId"), is(false)); + assertThat("body still carries the booking fields", body.contains("courtCentreId"), is(true)); + } + } + @Test void buildTypedJsonBodyShouldConvertDurationToNumber() { Map params = new HashMap<>(); From 7995a46c72cfde7c27e51dd6754df2a95191b035 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Sat, 4 Jul 2026 02:11:02 +0100 Subject: [PATCH 25/27] chore(SPRDT-1011): add .github secret-scanning workflow from main --- .github/actions/secret-scanner/action.yml | 67 +++++++++++++++++++ .../gitleaks-custom-rules-template.toml | 17 +++++ .github/workflows/secret-scanning.yml | 24 +++++++ 3 files changed, 108 insertions(+) create mode 100644 .github/actions/secret-scanner/action.yml create mode 100644 .github/actions/secret-scanner/gitleaks-custom-rules-template.toml create mode 100644 .github/workflows/secret-scanning.yml diff --git a/.github/actions/secret-scanner/action.yml b/.github/actions/secret-scanner/action.yml new file mode 100644 index 000000000..0948dba2a --- /dev/null +++ b/.github/actions/secret-scanner/action.yml @@ -0,0 +1,67 @@ +name: Secret Scanner + +description: Scans the code base to detect for the presence of secrets. + +inputs: + github_token: + required: true + description: GitHub token for authentication, required for accessing the repository and posting comments. + + gitleaks_license: + required: true + description: Gitleaks license key to use the licensed version. + + gitleaks_regex_internal_url: + required: true + description: Regex to identify internal urls + + gitleaks_regex_banned_ids: + required: true + description: Regex to identify banned IDs + +runs: + using: "composite" + steps: + - name: Prepare Gitleaks config + shell: bash + run: | + echo "Executing step: Prepare Gitleaks config" + set -euo pipefail + + # Validate that regex parameters are not empty or null + # Note: Missing secrets evaluate to empty strings in GitHub Actions + GITLEAKS_REGEX_INTERNAL_URL_VALUE="${{ inputs.gitleaks_regex_internal_url }}" + GITLEAKS_REGEX_BANNED_IDS_VALUE="${{ inputs.gitleaks_regex_banned_ids }}" + + if [ -z "$GITLEAKS_REGEX_INTERNAL_URL_VALUE" ] || [ "$GITLEAKS_REGEX_INTERNAL_URL_VALUE" = "null" ]; then + echo "::error::gitleaks_regex_internal_url is required and cannot be empty or null. Please ensure the secret is defined and has a value." + exit 1 + fi + + if [ -z "$GITLEAKS_REGEX_BANNED_IDS_VALUE" ] || [ "$GITLEAKS_REGEX_BANNED_IDS_VALUE" = "null" ]; then + echo "::error::gitleaks_regex_banned_ids is required and cannot be empty or null. Please ensure the secret is defined and has a value." + exit 1 + fi + + export GITLEAKS_REGEX_INTERNAL_URL="$GITLEAKS_REGEX_INTERNAL_URL_VALUE" + export GITLEAKS_REGEX_BANNED_IDS="$GITLEAKS_REGEX_BANNED_IDS_VALUE" + CUSTOM_CONFIG_FILE="$RUNNER_TEMP/gitleaks-custom-rules.toml" + envsubst < "${{ github.action_path }}/gitleaks-custom-rules-template.toml" > "$CUSTOM_CONFIG_FILE" + + # Set GITLEAKS_CONFIG env variable and make it available to subsequent steps + echo "GITLEAKS_CONFIG=$CUSTOM_CONFIG_FILE" >> "$GITHUB_ENV" + echo "------------ Scan will run with builtin + custom rules ------------" + + - name: Gitleaks scanning + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + GITLEAKS_LICENSE: ${{ inputs.gitleaks_license }} + GITLEAKS_ENABLE_COMMENTS: "false" # suppress PR comments + GITLEAKS_ENABLE_SUMMARY: "false" # suppress Job Summary + + - name: TruffleHog 🐽 scanning + uses: trufflesecurity/trufflehog@main + with: + extra_args: --results=verified + diff --git a/.github/actions/secret-scanner/gitleaks-custom-rules-template.toml b/.github/actions/secret-scanner/gitleaks-custom-rules-template.toml new file mode 100644 index 000000000..3f855cd99 --- /dev/null +++ b/.github/actions/secret-scanner/gitleaks-custom-rules-template.toml @@ -0,0 +1,17 @@ +title = "Custom rules" + +[extend] +useDefault = true + +[[rules]] +id = "internal-urls" +description = "Identify internal urls" +regex = '''${GITLEAKS_REGEX_INTERNAL_URL}''' +tags = ["internal-urls"] + +[[rules]] +id = "banned-ids" +description = "Identify banned IDs" +regex = '''${GITLEAKS_REGEX_BANNED_IDS}''' +tags = ["banned-ids"] + diff --git a/.github/workflows/secret-scanning.yml b/.github/workflows/secret-scanning.yml new file mode 100644 index 000000000..d31268822 --- /dev/null +++ b/.github/workflows/secret-scanning.yml @@ -0,0 +1,24 @@ +name: Scanning +on: + pull_request: + branches: + - '**' + schedule: + - cron: '0 4 * * 4' # Every Thursday at 04:00 + workflow_dispatch: + +jobs: + scan: + name: Secrets Scanner + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/secret-scanner + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + gitleaks_license: ${{ secrets.GITLEAKS_LICENSE }} + gitleaks_regex_internal_url: ${{ secrets.HMCTS_CP_GITLEAKS_REGEX_INTERNAL_URL }} + gitleaks_regex_banned_ids: ${{ secrets.HMCTS_CP_GITLEAKS_REGEX_BANNED_IDS }} \ No newline at end of file From 4f453e70a61d188220fb9a8ccdd95d729577521e Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Sat, 4 Jul 2026 11:36:14 +0100 Subject: [PATCH 26/27] fix(SPRDT-1011): CROWN multi-day update courtCentreId falls back to the selected court centre, not a court-schedule id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In enrichCrownUpdateHearing the courtCentreId query param passed to multiDaySearchAndBook fell back to anchorCourtScheduleId when hearing.getCourtCentreId() was null — a court-schedule UUID, not a court-centre UUID, so courtscheduler received a bogus courtCentreId (400 / mis-booking). Fall back to the hearing's own selected court centre (getSelectedCourtCentre().getId()) instead, mirroring the fallback pattern in the sibling handleCrownMultiDayEnrichment. --- .../api/service/CourtScheduleEnrichmentService.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 40a6156a5..8481aeece 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -293,11 +293,20 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL .map(nd -> nonNull(nd.getStartTime()) ? nd.getStartTime().toLocalDate() : null) .orElseGet(() -> firstDay != null ? firstDay.getHearingDate() : null); + // courtCentreId falls back to the hearing's own selected court centre, never to a + // court-schedule id (mirrors the fallback in handleCrownMultiDayEnrichment). + final String fallbackCourtCentreId = hearing.getSelectedCourtCentre() != null && hearing.getSelectedCourtCentre().getId() != null + ? hearing.getSelectedCourtCentre().getId().toString() + : ""; + final String courtCentreId = hearing.getCourtCentreId() != null + ? hearing.getCourtCentreId().toString() + : fallbackCourtCentreId; + final List sessions = multiDaySearchAndBook( anchorCourtScheduleId, totalDuration, hearing.getHearingId().toString(), - hearing.getCourtCentreId() != null ? hearing.getCourtCentreId().toString() : anchorCourtScheduleId, + courtCentreId, anchorDate != null ? anchorDate.toString() : LocalDate.now().toString()); if (isEmpty(sessions)) { From f63b5e5c6d88ec1dc5796f894ee95f8bf963d840 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Sat, 4 Jul 2026 12:40:13 +0100 Subject: [PATCH 27/27] test(SPRDT-1011): scope MAGS search-and-book IT stubs by /hearings/{hearingId} path (follow c95cf7f49) c95cf7f49 removed hearingId from the crown/mags search-and-book request body (now path-only, to satisfy courtscheduler's additionalProperties:false schema) but left the MAGS search-and-book WireMock stubs matching on `containing("hearingId":"")` in the body. Post-change the body no longer carries hearingId, so those 5 stubs stopped matching -> "returned no sessions" -> hearings never allocated -> PayloadBasedListCourtHearingIT, ExhibitScenarioIT and PayloadBasedListNextHearingIT timed out on the viewstore poll (7 errors). Scope the 5 stubs by the hearingId in the URL path (/hearings/{hearingId}), mirroring the existing DELETE /sessions/{hearingId} stub, and drop the stale body matcher. Per-hearing scoping is preserved. The businessType body matcher and the PATCH/extend stubs (whose body still carries hearingId) are unchanged. --- .../listing/utils/CourtSchedulerServiceStub.java | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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 95e744f6f..77a01c0a1 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 @@ -1238,9 +1238,8 @@ public static void stubSearchBookHearingSlots(final String hearingId, final Stri // Request body now carries the params (hearingId, courtCentreId, hearingDate, etc.) // Match on hearingId in request body + content-type; stub scoped to this hearing - stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1272,9 +1271,8 @@ public static void stubSearchBookHearingSlotsWithBusinessType(final String heari " ]\n" + "}"; - stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .withRequestBody(containing("\"businessType\":\"" + businessType + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) @@ -1306,9 +1304,8 @@ public static void stubSearchBookHearingSlotsForDraftSessions(final String heari " ]\n" + "}"; - stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1338,9 +1335,8 @@ public static void stubSearchBookHearingSlotsForCrown(final String hearingId, fi " ]\n" + "}"; - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1370,9 +1366,8 @@ public static void stubSearchBookHearingSlotsForCrownDraft(final String hearingI " ]\n" + "}"; - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON)