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 ae219effd..462f4881a 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 @@ -46,7 +46,11 @@ import uk.gov.moj.cpp.listing.common.service.HearingSlotsService; import uk.gov.moj.cpp.listing.domain.VacateTrialEnriched; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -89,13 +93,19 @@ public class ListingCommandApi { 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_END_TIME = "endTime"; 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"; public static final String FUTURE_DATE_NOT_ALLOWED = "FUTURE_DATE_NOT_ALLOWED"; + public static final String INVALID_DATE_RANGE = "INVALID_DATE_RANGE"; + public static final String START_DATE_TOO_OLD = "START_DATE_TOO_OLD"; + private static final String END_DATE = "endDate"; + private static final String HEARING_START_TIME = "hearingStartTime"; + private static final String START_TIME = "startTime"; + private static final String END_TIME = "endTime"; + private static final String SEQUENCE = "sequence"; + private static final int MAX_PAST_MONTHS = 6; 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"; @@ -369,7 +379,21 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { 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 UUID courtRoomId = fromString(payload.getString(COURT_ROOM_ID)); + // startTime/endTime are absolute UTC instants (e.g. 2026-07-02T17:00:00.000Z); the day and + // time-of-day for the move are derived directly from them - no local->UTC conversion needed. + final String startTimeStr = payload.getString(START_TIME); + final String endTimeStr = (payload.containsKey(END_TIME) && !payload.isNull(END_TIME)) + ? payload.getString(END_TIME) : null; + final ZonedDateTime startInstant = ZonedDateTime.parse(startTimeStr); + final ZonedDateTime endInstant = endTimeStr != null ? ZonedDateTime.parse(endTimeStr) : startInstant; + final LocalDate startDate = startInstant.toLocalDate(); + final LocalDate endDate = endInstant.toLocalDate(); + final LocalTime startLocalTime = startInstant.toLocalTime(); + final String utcHearingStartTime = String.format("%02d:%02d", startInstant.getHour(), startInstant.getMinute()); + + validateMoveDates(startDate, endDate); + final List sittingDays = workingDaysBetween(startDate, endDate); final JsonObject hearing = hearingLookupService.findHearing(hearingId, envelope) .orElseThrow(() -> new MoveHearingToPastDateException(422, @@ -382,14 +406,16 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { .add(HEARING_ID, hearingId.toString()) .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType) .add(START_DATE, startDate.toString()) - .add(COURT_CENTRE_ID, courtCentreId.toString()); + .add(END_DATE, endDate.toString()) + .add(COURT_CENTRE_ID, courtCentreId.toString()) + .add(HEARING_START_TIME, utcHearingStartTime); if (CROWN_JURISDICTION.equals(jurisdictionType)) { - // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. - rejectCrownMoveToFutureDate(startDate); - enrichWithExistingDayDetails(enrichedBuilder, hearing, startDate); + // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called + // (Phase 2 will route CROWN through courtscheduler). + enrichWithExistingDayDetails(enrichedBuilder, hearing, sittingDays, courtRoomId, startLocalTime); } else { - enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing); + enrichWithBookedPastDateSlots(enrichedBuilder, hearingId, courtCentreId, courtRoomId, startTimeStr, endTimeStr, hearing); } sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED), @@ -397,66 +423,123 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { } /** - * 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. + * Validated for ALL jurisdictions in the synchronous layer so the caller gets a 422 before any + * event is sent or slot booked: no future dates, endDate not before startDate, and startDate no + * older than {@value #MAX_PAST_MONTHS} months before today. */ - 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()); + private static void validateMoveDates(final LocalDate startDate, final LocalDate endDate) { + final LocalDate today = LocalDate.now(); + if (startDate.isAfter(today) || endDate.isAfter(today)) { + 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"); } - 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 (endDate.isBefore(startDate)) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(INVALID_DATE_RANGE, "endDate must not be earlier than startDate"), + "endDate " + endDate + " is earlier than startDate " + startDate); } - if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) { - enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES)); + if (startDate.isBefore(today.minusMonths(MAX_PAST_MONTHS))) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(START_DATE_TOO_OLD, + "startDate cannot be earlier than " + MAX_PAST_MONTHS + " months before today"), + "startDate " + startDate + " is more than " + MAX_PAST_MONTHS + " months in the past"); } } - private static void rejectCrownMoveToFutureDate(final LocalDate startDate) { - if (startDate.isAfter(LocalDate.now())) { + /** + * Expands an inclusive [startDate, endDate] span into sitting (Mon-Fri) days. Courts do not sit at + * weekends so a multi-day move skips Sat/Sun; a pure-weekend span has no sitting day and is rejected. + */ + private static List workingDaysBetween(final LocalDate startDate, final LocalDate endDate) { + final List days = new ArrayList<>(); + LocalDate cursor = startDate; + while (!cursor.isAfter(endDate)) { + final DayOfWeek dow = cursor.getDayOfWeek(); + if (dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) { + days.add(cursor); + } + cursor = cursor.plusDays(1); + } + if (days.isEmpty()) { 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"); + buildMoveHearingToPastDateErrorBody(INVALID_DATE_RANGE, "No sitting (weekday) day in the requested range"), + "No working day between " + startDate + " and " + endDate); } + return days; } - private void enrichWithBookedPastDateSlot(final JsonObjectBuilder enrichedBuilder, final UUID hearingId, - final UUID courtCentreId, final LocalDate startDate, final JsonObject hearing) { + /** + * CROWN moves never call courtscheduler. Each sitting day is re-issued on the new past date at the + * requested UTC start time and in the requested room (both mandatory). Duration is copied from the + * hearing's own current first day. + */ + private static void enrichWithExistingDayDetails(final JsonObjectBuilder enrichedBuilder, final JsonObject hearing, + final List sittingDays, final UUID courtRoomId, + final LocalTime startLocalTime) { + final JsonArray existingDays = hearing.containsKey(HEARING_DAYS) ? hearing.getJsonArray(HEARING_DAYS) : null; + final JsonObject firstDay = (existingDays != null && !existingDays.isEmpty()) ? existingDays.getJsonObject(0) : null; + + final Integer durationInMinutes = (firstDay != null && firstDay.containsKey(DAY_DURATION_MINUTES) && !firstDay.isNull(DAY_DURATION_MINUTES)) + ? firstDay.getInt(DAY_DURATION_MINUTES) : null; + + final JsonArrayBuilder daysBuilder = createArrayBuilder(); + int sequence = 1; + for (final LocalDate day : sittingDays) { + final ZonedDateTime start = ZonedDateTime.of(day, startLocalTime, ZoneOffset.UTC); + final JsonObjectBuilder dayBuilder = createObjectBuilder() + .add(SESSION_DATE, day.toString()) + .add(SESSION_START_TIME, start.toString()) + .add(SEQUENCE, sequence++) + .add(COURT_ROOM_ID, courtRoomId.toString()); + if (durationInMinutes != null) { + dayBuilder.add(DURATION_IN_MINUTES, durationInMinutes); + dayBuilder.add(SESSION_END_TIME, start.plusMinutes(durationInMinutes).toString()); + } + daysBuilder.add(dayBuilder); + } + enrichedBuilder.add(HEARING_DAYS, daysBuilder); + } + + /** + * MAGS path. courtscheduler owns the multi-day expansion and books every sitting day in one atomic + * call (releasing prior allocations once), returning one booked slot per day, which we map into the + * enriched hearingDays[]. A no-session/booking failure surfaces synchronously as a 422. + */ + private void enrichWithBookedPastDateSlots(final JsonObjectBuilder enrichedBuilder, final UUID hearingId, + final UUID courtCentreId, final UUID courtRoomId, final String startTime, + final String endTime, 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()); - } + final List slots = courtSchedulerServiceAdapter.moveHearingToPastDate( + hearingId, courtCentreId, courtRoomId, startTime, endTime, durationInMinutes); + + final JsonArrayBuilder daysBuilder = createArrayBuilder(); + int sequence = 1; + for (final MoveHearingToPastDateResult slot : slots) { + final JsonObjectBuilder dayBuilder = createObjectBuilder().add(SEQUENCE, sequence++); + if (slot.sessionDate() != null) { + dayBuilder.add(SESSION_DATE, slot.sessionDate().toString()); + } + if (slot.courtScheduleId() != null) { + dayBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); + } + if (slot.courtRoomId() != null) { + dayBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); + } + if (slot.sessionStartTime() != null) { + dayBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); + } + if (slot.sessionEndTime() != null) { + dayBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); + } + if (slot.durationInMinutes() != null) { + dayBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); + } + daysBuilder.add(dayBuilder); + } + enrichedBuilder.add(HEARING_DAYS, daysBuilder); } private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) { 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 ea5235ca8..599ef8437 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,4 +1,6 @@ { "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", - "startDate": "2026-05-01" + "courtRoomId": "731816c1-5ee4-373a-9bda-840e13a5bcb0", + "startTime": "2026-07-01T09:00:00.000Z", + "endTime": "2026-07-09T09:00:00.000Z" } 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 0327ea7fd..5ab15c24e 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 @@ -6,14 +6,22 @@ "courtCentreId": { "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, - "startDate": { + "courtRoomId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "startTime": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]{1,3})?Z$" + }, + "endTime": { "type": "string", - "format": "date" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]{1,3})?Z$" } }, "required": [ "courtCentreId", - "startDate" + "courtRoomId", + "startTime" ], "additionalProperties": false } 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 5308dbee2..49858a044 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 @@ -597,15 +597,33 @@ public void shouldVacateTheTrial() { verify(sender, times(1)).send(senderJsonEnvelopeCaptor.capture()); } + // most recent weekday strictly before today - always a valid (past, within 6 months) sitting day + private static LocalDate lastWorkingDayBeforeToday() { + LocalDate day = LocalDate.now().minusDays(1); + while (day.getDayOfWeek() == java.time.DayOfWeek.SATURDAY || day.getDayOfWeek() == java.time.DayOfWeek.SUNDAY) { + day = day.minusDays(1); + } + return day; + } + + private static final UUID MOVE_COURT_ROOM_ID = randomUUID(); + + // startTime is an absolute UTC instant (e.g. 2026-07-02T10:00:00.000Z); the request carries no + // separate date / local-time fields. courtRoomId is mandatory, stubbed to MOVE_COURT_ROOM_ID. + private void stubMovePayload(final UUID hearingId, final UUID courtCentreId, final LocalDate startDate, final String utcTimeHhmm) { + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("courtRoomId")).willReturn(MOVE_COURT_ROOM_ID.toString()); + given(payload.getString("startTime")).willReturn(startDate + "T" + utcTimeHhmm + ":00.000Z"); + } + @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"); + stubMovePayload(hearingId, courtCentreId, lastWorkingDayBeforeToday(), "10:00"); given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.empty()); final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, @@ -616,17 +634,54 @@ public void shouldRejectMoveHearingToPastDateWhenHearingIdUnknown() { verify(sender, never()).send(any()); } + @Test + public void shouldRejectMoveToFutureDate() { + stubMovePayload(randomUUID(), randomUUID(), LocalDate.now().plusDays(1), "10:00"); + + 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 shouldRejectMoveWhenStartDateOlderThanSixMonths() { + stubMovePayload(randomUUID(), randomUUID(), LocalDate.now().minusMonths(7), "10:00"); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("START_DATE_TOO_OLD")); + verify(sender, never()).send(any()); + } + + @Test + public void shouldRejectMoveWhenEndDateBeforeStartDate() { + final LocalDate startDate = lastWorkingDayBeforeToday(); + stubMovePayload(randomUUID(), randomUUID(), startDate, "10:00"); + given(payload.containsKey("endTime")).willReturn(true); + given(payload.isNull("endTime")).willReturn(false); + given(payload.getString("endTime")).willReturn(startDate.minusDays(1) + "T10:00:00.000Z"); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("INVALID_DATE_RANGE")); + 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"); + final LocalDate startDate = lastWorkingDayBeforeToday(); - 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()); + stubMovePayload(hearingId, courtCentreId, startDate, "10:00"); given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); final JsonObject hearing = Json.createObjectBuilder() @@ -637,64 +692,92 @@ public void shouldMoveMagistratesHearingToPastDateEnrichWithSlotDetailsAndSend() 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); + "9d324f4f-6c3b-451f-ac1e-f459db781153", startDate, startDate + "T09:00:00Z", startDate + "T12:00:00Z", 30); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any(), any(), any())) + .willReturn(List.of(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)); + final JsonArray days = sent.getJsonArray("hearingDays"); + assertThat(days.size(), is(1)); + assertThat(days.getJsonObject(0).getString("courtScheduleId"), is(courtScheduleId.toString())); + assertThat(days.getJsonObject(0).getString("sessionDate"), is(startDate.toString())); + assertThat(days.getJsonObject(0).getInt("durationInMinutes"), is(30)); + assertThat(days.getJsonObject(0).getInt("sequence"), is(1)); } @Test - public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() { + public void shouldMoveMagistratesMultiDayEnrichWithSlotArray() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); - final LocalDate startDate = LocalDate.parse("2999-01-01"); + final LocalDate startDate = lastWorkingDayBeforeToday(); - 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()); + stubMovePayload(hearingId, courtCentreId, startDate, "10:00"); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); final JsonObject hearing = Json.createObjectBuilder() - .add("id", hearingId.toString()) - .add("jurisdictionType", "MAGISTRATES") - .build(); + .add("id", hearingId.toString()).add("jurisdictionType", "MAGISTRATES").add("estimatedMinutes", 30).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 MoveHearingToPastDateResult day1 = new MoveHearingToPastDateResult(randomUUID(), + "room-1", LocalDate.parse("2026-07-01"), "2026-07-01T09:00:00Z", "2026-07-01T12:00:00Z", 30); + final MoveHearingToPastDateResult day2 = new MoveHearingToPastDateResult(randomUUID(), + "room-1", LocalDate.parse("2026-07-02"), "2026-07-02T09:00:00Z", "2026-07-02T12:00:00Z", 30); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any(), any(), any())) + .willReturn(List.of(day1, day2)); - 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()); + final ArgumentCaptor captor = forClass(Envelope.class); + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(sender, times(1)).send(captor.capture()); + final JsonArray days = ((JsonObject) captor.getValue().payload()).getJsonArray("hearingDays"); + assertThat(days.size(), is(2)); + assertThat(days.getJsonObject(0).getInt("sequence"), is(1)); + assertThat(days.getJsonObject(0).getString("sessionDate"), is("2026-07-01")); + assertThat(days.getJsonObject(1).getInt("sequence"), is(2)); + assertThat(days.getJsonObject(1).getString("sessionDate"), is("2026-07-02")); + } + + @Test + public void shouldPassRequestedRoomAndUtcTimeToCourtschedulerForMagistratesMove() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = lastWorkingDayBeforeToday(); + + stubMovePayload(hearingId, courtCentreId, startDate, "10:00"); + 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)); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any(), any(), any())) + .willReturn(List.of(new MoveHearingToPastDateResult(randomUUID(), MOVE_COURT_ROOM_ID.toString(), startDate, + startDate + "T09:00:00Z", startDate + "T12:00:00Z", 30))); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + final ArgumentCaptor roomCaptor = forClass(UUID.class); + final ArgumentCaptor startTimeCaptor = forClass(String.class); + verify(courtSchedulerServiceAdapter).moveHearingToPastDate(eq(hearingId), eq(courtCentreId), + roomCaptor.capture(), startTimeCaptor.capture(), any(), eq(30)); + assertThat(roomCaptor.getValue(), is(MOVE_COURT_ROOM_ID)); + // startTime is the absolute UTC instant from the request, forwarded unchanged (no conversion) + assertThat(startTimeCaptor.getValue(), is(startDate + "T10:00:00.000Z")); } @Test public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); - final LocalDate startDate = LocalDate.parse("2026-05-01"); + final LocalDate startDate = lastWorkingDayBeforeToday(); - 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()); + stubMovePayload(hearingId, courtCentreId, startDate, "10:00"); final JsonObject hearing = Json.createObjectBuilder() .add("id", hearingId.toString()) @@ -706,7 +789,7 @@ public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() { .add("errorCode", "NO_SESSION_FOUND") .add("message", "No court-schedule session found") .build(); - given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any(), any(), any())) .willThrow(new MoveHearingToPastDateException(422, noSessionBody, "no session")); final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, @@ -720,12 +803,9 @@ public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() { public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); - final LocalDate startDate = LocalDate.now().minusDays(1); + final LocalDate startDate = lastWorkingDayBeforeToday(); - 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()); + stubMovePayload(hearingId, courtCentreId, startDate, "10:00"); given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); final JsonObject hearing = Json.createObjectBuilder() @@ -738,26 +818,26 @@ public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtSc listingCommandApi.handleMoveHearingToPastDate(envelope); - verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), 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)); + final JsonArray days = sent.getJsonArray("hearingDays"); + assertThat(days.size(), is(1)); + assertThat(days.getJsonObject(0).getString("sessionDate"), is(startDate.toString())); + assertThat(days.getJsonObject(0).containsKey("courtScheduleId"), is(false)); } @Test - public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() { + public void shouldEnrichCrownMoveWithRequestedTimeAndExistingRoomAndDuration() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); final UUID crownRoomId = randomUUID(); - final LocalDate startDate = LocalDate.now().minusDays(1); + final LocalDate startDate = lastWorkingDayBeforeToday(); - 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()); + stubMovePayload(hearingId, courtCentreId, startDate, "10:30"); given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); final JsonObject hearing = Json.createObjectBuilder() @@ -766,7 +846,7 @@ public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() { .add("hearingDays", Json.createArrayBuilder() .add(Json.createObjectBuilder() .add("hearingDate", LocalDate.now().plusDays(3).toString()) - .add("startTime", LocalDate.now().plusDays(3) + "T10:30:00Z") + .add("startTime", LocalDate.now().plusDays(3) + "T14:00:00Z") .add("durationMinutes", 45) .add("courtRoomId", crownRoomId.toString()))) .build(); @@ -776,40 +856,19 @@ public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() { listingCommandApi.handleMoveHearingToPastDate(envelope); - verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), 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(); - 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()); + final JsonObject day0 = sent.getJsonArray("hearingDays").getJsonObject(0); + assertThat(day0.getString("sessionDate"), is(startDate.toString())); + // courtRoomId now comes from the (mandatory) request, not the hearing's existing day + assertThat(day0.getString("courtRoomId"), is(MOVE_COURT_ROOM_ID.toString())); + assertThat(day0.getInt("durationInMinutes"), is(45)); + assertThat(day0.containsKey("courtScheduleId"), is(false)); + // start-time is the requested absolute UTC instant on the new past date (no conversion) + final java.time.ZonedDateTime start = java.time.ZonedDateTime.parse(day0.getString("sessionStartTime")); + assertThat(start.toLocalDate(), is(startDate)); + assertThat(start.withZoneSameInstant(java.time.ZoneOffset.UTC).toLocalTime(), is(java.time.LocalTime.of(10, 30))); } @Test 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 d517b4cab..0e546522d 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 @@ -199,6 +199,8 @@ public class ListingCommandHandler { private static final String SESSION_END_TIME = "sessionEndTime"; private static final String DURATION_IN_MINUTES = "durationInMinutes"; private static final String CROWN_JURISDICTION = "CROWN"; + private static final String HEARING_DAYS = "hearingDays"; + private static final String SEQUENCE = "sequence"; @Inject private EventSource eventSource; @@ -425,58 +427,60 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream 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)) { - 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 sessionDate = parse(payload.getString(SESSION_DATE)); - final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); - 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, - uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES, emptyList()))); - } - } - - 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)) + + // 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 every sitting day on the new past date instead: + // MAGS carries the slot(s) booked by courtscheduler (one per working day), CROWN carries the + // hearing's own room + the requested hearingStartTime (enriched by command-api - courtscheduler + // is never called for CROWN before Phase 2, Baris decision D1). + final JsonArray days = payload.getJsonArray(HEARING_DAYS); + final List movedDays = new ArrayList<>(); + for (int i = 0; i < days.size(); i++) { + movedDays.add(buildMovedHearingDay(days.getJsonObject(i), courtCentreId, i + 1)); + } + + final uk.gov.justice.core.courts.JurisdictionType jurisdictionType = CROWN_JURISDICTION.equals(jurisdiction) + ? uk.gov.justice.core.courts.JurisdictionType.CROWN + : uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES; + + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, movedDays, null, null, jurisdictionType, emptyList()))); + } + + private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(final JsonObject day, + final Optional courtCentreId, + final int defaultSequence) { + final LocalDate dayDate = parse(day.getString(SESSION_DATE)); + final Optional courtScheduleId = day.containsKey(COURT_SCHEDULE_ID) + ? Optional.of(fromString(day.getString(COURT_SCHEDULE_ID))) : Optional.empty(); + final Optional courtRoomId = day.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(day.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = day.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(day.getString(SESSION_START_TIME)) : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); - final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) - ? payload.getInt(DURATION_IN_MINUTES) : null; + final Integer durationInMinutes = day.containsKey(DURATION_IN_MINUTES) + ? day.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. + // always compute it as startTime + duration, so mirror that when the day has no end time. final ZonedDateTime dayEndTime; - if (payload.containsKey(SESSION_END_TIME)) { - dayEndTime = ZonedDateTime.parse(payload.getString(SESSION_END_TIME)); + if (day.containsKey(SESSION_END_TIME)) { + dayEndTime = ZonedDateTime.parse(day.getString(SESSION_END_TIME)); } else if (durationInMinutes != null) { dayEndTime = dayStartTime.plusMinutes(durationInMinutes); } else { dayEndTime = dayStartTime; } + final int sequence = day.containsKey(SEQUENCE) ? day.getInt(SEQUENCE) : defaultSequence; return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() .withHearingDate(dayDate) .withStartTime(dayStartTime) .withEndTime(dayEndTime) .withDurationMinutes(durationInMinutes) - .withSequence(1) + .withSequence(sequence) .withCourtScheduleId(courtScheduleId) .withCourtCentreId(courtCentreId) .withCourtRoomId(courtRoomId) @@ -1490,7 +1494,7 @@ public void correctHearingDaysWithoutCourtCentre(final JsonEnvelope commandEnvel final List hearingDays = new ArrayList<>(); - payload.getJsonArray("hearingDays").getValuesAs(JsonObject.class).stream() + payload.getJsonArray(HEARING_DAYS).getValuesAs(JsonObject.class).stream() .forEach(hearingDay -> hearingDays.add(jsonObjectConverter.convert(hearingDay, uk.gov.justice.listing.events.HearingDay.class))); updateHearingEventStream(commandEnvelope, hearingId, (Hearing hearing) -> hearing.raiseHearingDaysWithoutCourtCentreCorrected(hearingId, hearingDays)); 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 dca55cc54..b32a59465 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 @@ -1,12 +1,28 @@ { "hearingId": "0baecac5-222b-402d-9047-84803679edae", "jurisdiction": "MAGISTRATES", - "startDate": "2026-05-01", + "startDate": "2026-07-01", + "endDate": "2026-07-02", "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", - "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 + "hearingStartTime": "09:00", + "hearingDays": [ + { + "sessionDate": "2026-07-01", + "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", + "courtRoomId": "731816c1-5ee4-373a-9bda-840e13a5bcb0", + "sessionStartTime": "2026-07-01T09:00:00Z", + "sessionEndTime": "2026-07-01T12:00:00Z", + "durationInMinutes": 30, + "sequence": 1 + }, + { + "sessionDate": "2026-07-02", + "courtScheduleId": "9d324f4f-6c3b-451f-ac1e-f459db781153", + "courtRoomId": "731816c1-5ee4-373a-9bda-840e13a5bcb0", + "sessionStartTime": "2026-07-02T09:00:00Z", + "sessionEndTime": "2026-07-02T12:00:00Z", + "durationInMinutes": 30, + "sequence": 2 + } + ] } 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 7de8fda09..495267500 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,34 +14,58 @@ "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" - }, - "courtRoomId": { - "type": "string" - }, - "sessionDate": { + "endDate": { "type": "string", "format": "date" }, - "sessionStartTime": { - "type": "string" + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, - "sessionEndTime": { + "hearingStartTime": { "type": "string" }, - "durationInMinutes": { - "type": "integer" + "hearingDays": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "sessionDate": { + "type": "string", + "format": "date" + }, + "courtScheduleId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtRoomId": { + "type": "string" + }, + "sessionStartTime": { + "type": "string" + }, + "sessionEndTime": { + "type": "string" + }, + "durationInMinutes": { + "type": "integer" + }, + "sequence": { + "type": "integer" + } + }, + "required": [ + "sessionDate" + ], + "additionalProperties": false + } } }, "required": [ "hearingId", "jurisdiction", "startDate", - "courtCentreId" + "courtCentreId", + "hearingDays" ], "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 9f09a160b..a457a34dc 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 @@ -2599,6 +2599,27 @@ public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly assertThat(movedDay.getCourtRoomId().orElse(null), is(crownRoomId)); } + @Test + public void listingCommandHandlerShouldMoveMagistratesMultiDayHearingToPastDate() throws Exception { + final JsonEnvelope commandEnvelope = getEnvelopeForMoveMultiDayHearingToPastDate(); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.changeStartDate(eq(LocalDate.parse("2026-07-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)).changeStartDate(LocalDate.parse("2026-07-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())); + assertThat(captor.getValue().size(), is(2)); + assertThat(captor.getValue().get(0).getHearingDate(), is(LocalDate.parse("2026-07-01"))); + assertThat(captor.getValue().get(1).getHearingDate(), is(LocalDate.parse("2026-07-02"))); + } + @Test public void listingCommandHandlerShouldHearingVacateTrial() throws Exception { final JsonEnvelope commandEnvelope = getEnvelopeForHearingVacateTrial(REASON); @@ -2791,16 +2812,28 @@ 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 + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtScheduleId\":\"" + courtScheduleId - + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + + sessionDate + "\",\"endDate\":\"" + sessionDate + "\",\"courtCentreId\":\"" + randomUUID() + + "\",\"hearingStartTime\":\"09:00\",\"hearingDays\":[{\"sessionDate\":\"" + sessionDate + + "\",\"courtScheduleId\":\"" + courtScheduleId + "\",\"sessionStartTime\":\"" + sessionDate + + "T09:00:00Z\",\"sessionEndTime\":\"" + sessionDate + "T12:00:00Z\",\"durationInMinutes\":30,\"sequence\":1}]}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + + private JsonEnvelope getEnvelopeForMoveMultiDayHearingToPastDate() { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"2026-07-01\"," + + "\"endDate\":\"2026-07-02\",\"courtCentreId\":\"" + randomUUID() + "\",\"hearingStartTime\":\"09:00\",\"hearingDays\":[" + + "{\"sessionDate\":\"2026-07-01\",\"courtScheduleId\":\"" + randomUUID() + "\",\"sessionStartTime\":\"2026-07-01T09:00:00Z\",\"durationInMinutes\":30,\"sequence\":1}," + + "{\"sessionDate\":\"2026-07-02\",\"courtScheduleId\":\"" + randomUUID() + "\",\"sessionStartTime\":\"2026-07-02T09:00:00Z\",\"durationInMinutes\":30,\"sequence\":2}]}"; 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 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}"; + + "\",\"endDate\":\"" + startDate + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"hearingStartTime\":\"10:00\",\"hearingDays\":[{" + + "\"sessionDate\":\"" + startDate + "\",\"courtRoomId\":\"" + courtRoomId + "\",\"sessionStartTime\":\"" + startDate + + "T10:00:00Z\",\"durationInMinutes\":25,\"sequence\":1}]}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } 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 6c82e3661..9a269b221 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 @@ -53,7 +53,9 @@ public class CourtSchedulerServiceAdapter { public static final String HEARING_ID = "hearingId"; public static final String COURT_CENTRE_ID = "courtCentreId"; public static final String JURISDICTION = "jurisdiction"; - public static final String START_DATE = "startDate"; + public static final String START_TIME = "startTime"; + public static final String END_TIME = "endTime"; + public static final String BOOKED_SLOTS = "bookedSlots"; public static final String DURATION_IN_MINUTES = "durationInMinutes"; public static final String MAGISTRATES_JURISDICTION = "MAGISTRATES"; public static final String NO_SESSION_FOUND = "NO_SESSION_FOUND"; @@ -234,20 +236,31 @@ HearingIdsResponse getHearingIds(final Response response) { } /** - * 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 + * MAGISTRATES-only from listing today (courtscheduler itself now supports both jurisdictions; + * CROWN moves stay listing-side until Phase 2 - Baris decision D1). courtscheduler expands + * [startDate, endDate] into sitting days and books every day in one atomic call, returning one + * booked slot per day. On any non-200 the upstream errorCode/status is surfaced via * {@link MoveHearingToPastDateException} so the caller sends no event. + * + * @param hearingStartTime the requested start time already converted to UTC (HH:mm), matched by + * courtscheduler against the UTC session windows (range-containment). */ - 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 + public List moveHearingToPastDate(final UUID hearingId, + final UUID courtCentreId, + final UUID courtRoomId, + final String startTime, + final String endTime, + final Integer durationInMinutes) { + // hearingId travels only in the URL path; courtscheduler's REST adapter injects it. + // startTime/endTime are absolute UTC instants (e.g. 2026-07-02T17:00:00.000Z). final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() .add(COURT_CENTRE_ID, courtCentreId.toString()) + .add(COURT_ROOM_ID, courtRoomId.toString()) .add(JURISDICTION, MAGISTRATES_JURISDICTION) - .add(START_DATE, startDate.toString()); + .add(START_TIME, startTime); + if (endTime != null) { + requestBuilder.add(END_TIME, endTime); + } if (durationInMinutes != null) { requestBuilder.add(DURATION_IN_MINUTES, durationInMinutes); } @@ -259,7 +272,7 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, : Json.createObjectBuilder().build(); if (HttpStatus.SC_OK == status) { - return parseMoveHearingToPastDateResult(body); + return parseMoveHearingToPastDateResults(body); } LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", @@ -271,7 +284,7 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, 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)) + "No court-schedule session found for hearingId " + hearingId + " between " + startTime + " and " + endTime)) .build(); throw new MoveHearingToPastDateException(HttpStatus.SC_UNPROCESSABLE_ENTITY, noSessionBody, "moveHearingToPastDate found no session for hearingId " + hearingId); @@ -281,13 +294,24 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); } - private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { + private static List parseMoveHearingToPastDateResults(final JsonObject body) { + final List results = new ArrayList<>(); + final JsonArray bookedSlots = body.containsKey(BOOKED_SLOTS) ? body.getJsonArray(BOOKED_SLOTS) : null; + if (bookedSlots != null) { + for (int i = 0; i < bookedSlots.size(); i++) { + results.add(parseMoveHearingToPastDateResult(bookedSlots.getJsonObject(i))); + } + } + return results; + } + + private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject slot) { return new MoveHearingToPastDateResult( - body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : 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(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); + slot.containsKey("courtScheduleId") ? UUID.fromString(slot.getString("courtScheduleId")) : null, + slot.getString(COURT_ROOM_ID, null), + slot.containsKey("sessionDate") ? LocalDate.parse(slot.getString("sessionDate")) : null, + slot.getString("sessionStartTime", null), + slot.getString("sessionEndTime", null), + slot.containsKey(DURATION_IN_MINUTES) ? slot.getInt(DURATION_IN_MINUTES) : null); } } 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 94f6b787e..17ccdae9a 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 @@ -1,8 +1,10 @@ package uk.gov.moj.cpp.listing.common.service; +import static javax.json.Json.createArrayBuilder; import static javax.json.Json.createObjectBuilder; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -14,9 +16,11 @@ import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; import java.time.LocalDate; +import java.util.List; import java.util.UUID; import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; import javax.ws.rs.core.Response; import org.apache.http.HttpStatus; @@ -30,6 +34,11 @@ @ExtendWith(MockitoExtension.class) class CourtSchedulerServiceAdapterMoveHearingToPastDateTest { + // Absolute UTC instants sent as the move request's startTime/endTime (no separate date/local-time). + private static final String START_TIME = "2026-05-01T09:00:00.000Z"; + private static final String DAY_1_TIME = "2026-07-01T09:00:00.000Z"; + private static final String DAY_2_TIME = "2026-07-02T09:00:00.000Z"; + @InjectMocks private CourtSchedulerServiceAdapter adapter; @@ -39,51 +48,80 @@ class CourtSchedulerServiceAdapterMoveHearingToPastDateTest { @Mock private Response response; + private static JsonObjectBuilder slot(final String courtScheduleId, final String sessionDate) { + return createObjectBuilder() + .add("courtScheduleId", courtScheduleId) + .add("courtRoomId", "9d324f4f-6c3b-451f-ac1e-f459db781153") + .add("sessionDate", sessionDate) + .add("sessionStartTime", sessionDate + "T09:00:00Z") + .add("sessionEndTime", sessionDate + "T12:00:00Z") + .add("durationInMinutes", 30) + .add("source", "MOVE_TO_PAST_DATE"); + } + @Test - void shouldParseSlotDetailsOn200() { + void shouldParseSingleBookedSlotOn200() { 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") + .add("bookedSlots", createArrayBuilder().add(slot(courtScheduleId.toString(), "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, courtCentreId, startDate, 30); + final List result = + adapter.moveHearingToPastDate(hearingId, courtCentreId, UUID.randomUUID(), START_TIME, null, 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)); + assertThat(result, hasSize(1)); + assertThat(result.get(0).courtScheduleId(), is(courtScheduleId)); + assertThat(result.get(0).courtRoomId(), is("9d324f4f-6c3b-451f-ac1e-f459db781153")); + assertThat(result.get(0).sessionDate(), is(LocalDate.parse("2026-05-01"))); + assertThat(result.get(0).sessionStartTime(), is("2026-05-01T09:00:00Z")); + assertThat(result.get(0).durationInMinutes(), is(30)); + } + + @Test + void shouldParseMultipleBookedSlotsForAMultiDayMove() { + final UUID hearingId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder() + .add("bookedSlots", createArrayBuilder() + .add(slot(UUID.randomUUID().toString(), "2026-07-01")) + .add(slot(UUID.randomUUID().toString(), "2026-07-02"))) + .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 List result = adapter.moveHearingToPastDate( + hearingId, UUID.randomUUID(), UUID.randomUUID(), DAY_1_TIME, DAY_2_TIME, 30); + + assertThat(result, hasSize(2)); + assertThat(result.get(0).sessionDate(), is(LocalDate.parse("2026-07-01"))); + assertThat(result.get(1).sessionDate(), is(LocalDate.parse("2026-07-02"))); } @Test void shouldOmitDurationInRequestWhenNotSupplied() { final UUID hearingId = UUID.randomUUID(); - final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) - .add("sessionDate", "2026-05-01").build(); + final JsonObject body = createObjectBuilder() + .add("bookedSlots", createArrayBuilder().add(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); + final List result = adapter.moveHearingToPastDate( + hearingId, UUID.randomUUID(), UUID.randomUUID(), START_TIME, null, null); - assertThat(result.durationInMinutes(), is(nullValue())); + assertThat(result.get(0).durationInMinutes(), is(nullValue())); } @Test @@ -98,7 +136,8 @@ void shouldThrowWith422AndErrorCodeWhenFutureDate() { 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)); + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + "2999-01-01T09:00:00.000Z", null, 30)); assertThat(ex.getHttpStatus(), is(422)); assertThat(ex.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); @@ -116,7 +155,8 @@ void shouldThrowWith422NoSessionFoundWhenCourtschedulerReturns422() { 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)); + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + START_TIME, null, 30)); assertThat(ex.getHttpStatus(), is(422)); assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); @@ -131,32 +171,39 @@ void shouldNormaliseLegacy404ToA422NoSessionFound() { 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)); + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + START_TIME, null, 30)); assertThat(ex.getHttpStatus(), is(HttpStatus.SC_UNPROCESSABLE_ENTITY)); assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); } @Test - void shouldNotSendHearingIdInRequestBody() { + void shouldSendNewFieldsAndOmitHearingIdInRequestBody() { 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(); + final UUID courtRoomId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder() + .add("bookedSlots", createArrayBuilder().add(slot(UUID.randomUUID().toString(), "2026-07-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); + adapter.moveHearingToPastDate(hearingId, courtCentreId, courtRoomId, DAY_1_TIME, DAY_2_TIME, 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("courtRoomId"), is(courtRoomId.toString())); assertThat(request.getString("jurisdiction"), is("MAGISTRATES")); - assertThat(request.getString("startDate"), is("2026-05-01")); + assertThat(request.getString("startTime"), is(DAY_1_TIME)); + assertThat(request.getString("endTime"), is(DAY_2_TIME)); + assertThat(request.containsKey("startDate"), is(false)); + assertThat(request.containsKey("hearingStartTime"), is(false)); 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 0e504871c..d2376d508 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 @@ -11,6 +11,7 @@ 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.stubMoveHearingToPastDateMultiDay; 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; @@ -21,9 +22,11 @@ import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; import uk.gov.moj.cpp.listing.steps.data.HearingsData; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -85,7 +88,7 @@ private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); - final LocalDate pastDate = ItClock.today().minusDays(1); + final LocalDate pastDate = pastWorkingDay(1); final String courtScheduleId = randomUUID().toString(); stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30); @@ -99,7 +102,7 @@ void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { @Test void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); - final LocalDate pastDate = ItClock.today().minusDays(1); + final LocalDate pastDate = pastWorkingDay(1); final String firstCourtScheduleId = randomUUID().toString(); stubMoveHearingToPastDate(moveSteps.getHearingId(), firstCourtScheduleId, COURT_ROOM_ID, pastDate, 30); @@ -132,7 +135,7 @@ void shouldRejectMagistratesMoveWith422WhenNoCourtScheduleSessionExists() { 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)); + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastWorkingDay(1)); assertThat(response.getStatus(), is(422)); assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); @@ -147,7 +150,7 @@ void shouldNormaliseLegacyCourtscheduler404ToA422NoSessionFound() { 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)); + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastWorkingDay(1)); assertThat(response.getStatus(), is(422)); assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); @@ -161,7 +164,7 @@ void shouldRejectMoveWith422WhenHearingIdUnknown() { final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); final UUID unknownHearingId = randomUUID(); - final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, ItClock.today().minusDays(1)); + final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, pastWorkingDay(1)); assertThat(response.getStatus(), is(422)); assertThat(response.readEntity(String.class), containsString("HEARING_ID_NOT_FOUND")); @@ -173,7 +176,17 @@ void shouldRejectMoveWith400WhenMandatoryFieldMissing() { final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); - final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(ItClock.today().minusDays(1)); + final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(pastWorkingDay(1)); + + assertThat(response.getStatus(), is(400)); + } + + @Test + void shouldRejectMoveWith400WhenCourtRoomIdMissing() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedWithMissingCourtRoom(pastWorkingDay(1)); assertThat(response.getStatus(), is(400)); } @@ -181,7 +194,7 @@ void shouldRejectMoveWith400WhenMandatoryFieldMissing() { @Test void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); - final LocalDate pastDate = ItClock.today().minusDays(1); + final LocalDate pastDate = pastWorkingDay(1); final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); @@ -200,4 +213,50 @@ void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); } + + @Test + void shouldMoveMagistratesHearingToPastDateOverAMultiDayRange() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + final LocalDate startDate = pastWorkingDay(2); + final LocalDate endDate = pastWorkingDay(1); + final String firstScheduleId = randomUUID().toString(); + final String secondScheduleId = randomUUID().toString(); + stubMoveHearingToPastDateMultiDay(moveSteps.getHearingId(), COURT_ROOM_ID, 30, + List.of(firstScheduleId, secondScheduleId), List.of(startDate, endDate)); + + final Response response = moveSteps.whenHearingIsMovedToPastDateRange(startDate, endDate, COURT_ROOM_ID); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateCalled(moveSteps.getHearingId()); + moveSteps.verifyHearingDayCount(2); + moveSteps.verifyCourtScheduleStored(firstScheduleId); + moveSteps.verifyCourtScheduleStored(secondScheduleId); + } + + @Test + void shouldMoveMagistratesHearingWithinTheRequestedRoom() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + final LocalDate pastDate = pastWorkingDay(1); + final String courtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30); + + final Response response = moveSteps.whenHearingIsMovedToPastDateRange(pastDate, pastDate, COURT_ROOM_ID); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateCalled(moveSteps.getHearingId()); + moveSteps.verifyCourtScheduleStored(courtScheduleId); + } + + /** n-th working (Mon-Fri) day strictly before ItClock.today() - keeps past-date moves off weekends. */ + private static LocalDate pastWorkingDay(final int n) { + LocalDate day = ItClock.today(); + int found = 0; + while (found < n) { + day = day.minusDays(1); + if (day.getDayOfWeek() != DayOfWeek.SATURDAY && day.getDayOfWeek() != DayOfWeek.SUNDAY) { + found++; + } + } + return day; + } } 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 4751a6baf..81a91bab0 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 @@ -37,11 +37,13 @@ public class MoveHearingToPastDateSteps extends AbstractIT { private final String hearingId; private final UUID courtCentreId; + private final UUID courtRoomId; public MoveHearingToPastDateSteps(final HearingsData hearingsData) { final HearingData hearingData = hearingsData.getHearingData().get(0); this.hearingId = hearingData.getId().toString(); this.courtCentreId = hearingData.getCourtCentreId(); + this.courtRoomId = hearingData.getCourtRoomId(); givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); } @@ -52,24 +54,49 @@ 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("%%COURT_CENTRE_ID%%", courtCentreId.toString()) - .replace("%%START_DATE%%", date.toString()); + .replace("%%COURT_ROOM_ID%%", courtRoomId.toString()) + .replace("%%START_TIME%%", utc(date)); return postMove(payload); } public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { - final String payload = "{\"startDate\":\"" + date + "\"}"; + // courtCentreId omitted (schema-mandatory); courtRoomId/startTime present so the 400 is + // unambiguously the missing centre. + final String payload = "{\"courtRoomId\":\"" + courtRoomId + "\",\"startTime\":\"" + utc(date) + "\"}"; return postMove(hearingId, payload); } + public Response whenHearingIsMovedWithMissingCourtRoom(final LocalDate date) { + // courtRoomId omitted (schema-mandatory); every other mandatory field present so the 400 is + // unambiguously the missing courtRoomId. + final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startTime\":\"" + utc(date) + "\"}"; + return postMove(hearingId, payload); + } + + /** A multi-day move over [startTime, endTime], scoped to a specific room. */ + public Response whenHearingIsMovedToPastDateRange(final LocalDate startDate, final LocalDate endDate, final String courtRoomId) { + final String payload = "{\"courtCentreId\":\"" + courtCentreId + + "\",\"courtRoomId\":\"" + courtRoomId + + "\",\"startTime\":\"" + utc(startDate) + + "\",\"endTime\":\"" + utc(endDate) + "\"}"; + return postMove(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. The target hearing is - * identified purely by the URL path - hearingId is not part of the body. */ + * steps' own courtCentreId/courtRoomId 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 = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startDate\":\"" + date + "\"}"; + final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"courtRoomId\":\"" + courtRoomId + + "\",\"startTime\":\"" + utc(date) + "\"}"; return postMove(otherHearingId.toString(), payload); } + /** Fixed 10:00 UTC instant for the given day, matching the move contract's absolute-UTC startTime. */ + private static String utc(final LocalDate date) { + return date + "T10:00:00.000Z"; + } + private Response postMove(final String payload) { return postMove(hearingId, payload); } @@ -93,6 +120,19 @@ public void verifyCourtScheduleStored(final String expectedCourtScheduleId) { ))); } + public void verifyHearingDayCount(final int expectedCount) { + 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.length()", is(expectedCount)) + ))); + } + public void verifyStartDateUpdated(final LocalDate expectedStartDate) { final String searchHearingUrl = String.format("%s/%s", getBaseUri(), format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); 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 d2b51a0a3..65a40b1ae 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 @@ -1440,19 +1440,31 @@ private static void stubCourtSchedulesByIdResponse(final String body) { // --- 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. */ + /** Stub a successful single-day POST /hearings/{hearingId} move-hearing-to-past-date response + * (courtscheduler now returns a {@code bookedSlots} array, one slot per sitting day). */ 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); + stubMoveHearingToPastDateMultiDay(hearingId, courtRoomId, durationInMinutes, + java.util.List.of(courtScheduleId), java.util.List.of(sessionDate)); + } + + /** Stub a successful multi-day move: one booked slot per (courtScheduleId, sessionDate) pair. */ + public static void stubMoveHearingToPastDateMultiDay(final String hearingId, + final String courtRoomId, + final int durationInMinutes, + final java.util.List courtScheduleIds, + final java.util.List sessionDates) { + final StringBuilder slots = new StringBuilder(); + for (int i = 0; i < courtScheduleIds.size(); i++) { + if (i > 0) { + slots.append(","); + } + slots.append(moveBookedSlotJson(hearingId, courtScheduleIds.get(i), courtRoomId, sessionDates.get(i), durationInMinutes)); + } + final String body = "{\"bookedSlots\":[" + slots + "]}"; stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) @@ -1461,6 +1473,16 @@ public static void stubMoveHearingToPastDate(final String hearingId, .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } + private static String moveBookedSlotJson(final String hearingId, final String courtScheduleId, final String courtRoomId, + final LocalDate sessionDate, final int durationInMinutes) { + return format( + "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," + + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + + "\"durationInMinutes\":%s,\"isDraft\":false,\"source\":\"MOVE_TO_PAST_DATE\"}", + hearingId, courtScheduleId, courtRoomId, sessionDate, + sessionDate + "T09:00:00Z", sessionDate + "T12:00:00Z", durationInMinutes); + } + /** 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, 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 ab58d1d4f..f293a4df4 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,4 +1,5 @@ { "courtCentreId": "%%COURT_CENTRE_ID%%", - "startDate": "%%START_DATE%%" + "courtRoomId": "%%COURT_ROOM_ID%%", + "startTime": "%%START_TIME%%" } 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 ab58d1d4f..f293a4df4 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,4 +1,5 @@ { "courtCentreId": "%%COURT_CENTRE_ID%%", - "startDate": "%%START_DATE%%" + "courtRoomId": "%%COURT_ROOM_ID%%", + "startTime": "%%START_TIME%%" }