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
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..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
@@ -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,27 @@ 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 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";
+ 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 +128,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 +362,113 @@ 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())
+ .add(COURT_CENTRE_ID, courtCentreId.toString());
+
+ 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);
+ }
+
+ sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED),
+ 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_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));
+ }
+ }
+
+ 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)
+ .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
deleted file mode 100644
index 4938ca466..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 COURT_SCHEDULE_OBJECT = "CourtSchedule";
- private static final String CREATE_ACTION = "Create";
-
- private PermissionConstants() {
- }
-
- public static String createCourtSchedulePermission() throws JsonProcessingException {
- final ExpectedPermission expectedPermission = builder()
- .withObject(COURT_SCHEDULE_OBJECT)
- .withAction(CREATE_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/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java
index 7abc089fe..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
@@ -15,7 +15,9 @@
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;
import uk.gov.justice.services.common.converter.ObjectToJsonObjectConverter;
import uk.gov.justice.services.messaging.JsonEnvelope;
@@ -43,6 +45,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;
@@ -60,11 +63,15 @@
@SuppressWarnings("java:S3776")
public class CourtScheduleEnrichmentService implements EnrichmentService {
private static final String HEARING_SLOTS = "hearingSlots";
+ // 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";
private static final String COURT_SCHEDULES = "courtSchedules";
+ private static final String SESSION_START_TIME = "sessionStartTime";
@Inject
private CourtSchedulerService courtSchedulerService;
@Inject
@@ -255,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;
@@ -266,19 +278,53 @@ 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);
+
+ // 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(
- firstDay.getCourtScheduleId().toString(),
+ anchorCourtScheduleId,
totalDuration,
- hearing.getHearingId().toString());
+ hearing.getHearingId().toString(),
+ courtCentreId,
+ anchorDate != null ? anchorDate.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 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();
@@ -314,8 +360,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);
@@ -328,7 +374,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()
@@ -341,6 +388,8 @@ private UpdateHearingForListing enrichCrownUpdateHearing(final UpdateHearingForL
hearingBuilder.withJudiciary(convertJudicialRoleDomainToCore(enrichedJudiciaries));
}
+ deriveCommandLevelCourtRoomFromFinalSessions(hearing, enrichedHearingDays, hearingBuilder);
+
return hearingBuilder.build();
}
@@ -397,23 +446,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()))
@@ -428,15 +475,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();
@@ -447,6 +497,133 @@ 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.
+ * 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());
+ }
+ }
+
+ /**
+ * 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;
+ }
+ return nonNull(hearing.getSelectedCourtCentre()) && nonNull(hearing.getSelectedCourtCentre().getCourtRoomId());
+ }
+
private UpdateHearingForListing handleCrownUpdateSearchAndBook(final UpdateHearingForListing hearing) {
List hearingDaysWithCourtScheduleId = new ArrayList<>();
List judicialRolesBySearchAndBook = new ArrayList<>();
@@ -691,9 +868,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(),
@@ -796,14 +972,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()))
@@ -833,10 +1012,25 @@ 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()
+ : 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()
+ : fallbackHearingDate;
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());
@@ -928,7 +1122,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)) {
@@ -954,11 +1148,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)) {
@@ -971,7 +1167,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<>();
}
@@ -1039,15 +1235,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());
@@ -1220,22 +1416,42 @@ 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 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)
+ 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<>();
- 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);
@@ -1295,7 +1511,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/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/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..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.createCourtSchedulePermission()));
+ 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/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..ea5235ca8
--- /dev/null
+++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json
@@ -0,0 +1,4 @@
+{
+ "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..0327ea7fd
--- /dev/null
+++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json
@@ -0,0 +1,19 @@
+{
+ "$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": {
+ "courtCentreId": {
+ "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid"
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date"
+ }
+ },
+ "required": [
+ "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..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.createCourtSchedulePermission;
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 shouldAllowUserWithCourtScheduleCreatePermissionToMoveHearingToPastDate() throws JsonProcessingException {
+ public void shouldAllowAuthorisedUserToMoveHearingToPastDate() {
final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE);
- given(userAndGroupProvider.hasPermission(action, createCourtSchedulePermission())).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,11 +343,12 @@ public void shouldAllowUserWithCourtScheduleCreatePermissionToMoveHearingToPastD
}
@Test
- public void shouldNotAllowUserWithoutCourtScheduleCreatePermissionToMoveHearingToPastDate() {
+ public void shouldNotAllowUnauthorisedUserToMoveHearingToPastDate() {
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 f5e633abf..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
@@ -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,221 @@ 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 shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() {
+ 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));
+
+ 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(422, noSessionBody, "no session"));
+
+ final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class,
+ () -> listingCommandApi.handleMoveHearingToPastDate(envelope));
+ assertThat(thrown.getHttpStatus(), is(422));
+ assertThat(thrown.getErrorCode(), is("NO_SESSION_FOUND"));
+ 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 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();
+ 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() {
@@ -942,7 +1167,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/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 303922071..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.createCourtSchedulePermission;
-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 shouldCreateSchedulePermission() throws JsonProcessingException {
- JsonNode actual = mapper.readTree(createCourtSchedulePermission());
- JsonNode expected = mapper.readTree(getPayload("create-court-schedule-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/CourtScheduleEnrichmentServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentServiceTest.java
index 0fda77697..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;
@@ -668,7 +671,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 +770,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 +847,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 +897,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 +1310,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 +1396,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();
@@ -1440,13 +1443,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)
@@ -1457,7 +1461,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);
@@ -1469,6 +1473,236 @@ 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_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 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