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> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(hearingSlotsService, atLeastOnce()).multiDaySearchAndBook(paramsCaptor.capture()); + final Map params = paramsCaptor.getValue(); + assertThat(params.get(CourtScheduleEnrichmentService.DURATION_MINUTES), is("1440")); + assertThat(params.get("courtScheduleId"), is(anchorCsId.toString())); + assertThat(params.get(CourtScheduleEnrichmentService.HEARING_DATE), is(day1.toString())); + + // 4 booked days; the genuine nonDefaultDay's date keeps ITS start time (09:00, endTime follows), + // every other day keeps the session start time (10:00). + assertThat(result.getHearingDays().size(), is(4)); + final HearingDay genuine = result.getHearingDays().stream() + .filter(d -> genuineDay.equals(d.getHearingDate())).findFirst().orElseThrow(); + assertThat(genuine.getStartTime(), is(ZonedDateTime.parse(genuineDay + "T09:00:00Z"))); + assertThat(genuine.getEndTime(), is(ZonedDateTime.parse(genuineDay + "T15:00:00Z"))); + final HearingDay anchorDay = result.getHearingDays().stream() + .filter(d -> day1.equals(d.getHearingDate())).findFirst().orElseThrow(); + assertThat(anchorDay.getStartTime(), is(ZonedDateTime.parse(day1 + "T10:00:00Z"))); + } + + @Test + void shouldMarkDaysDraftAndSuppressAllocation_whenCrownUpdateSingleDayFetchReturnsEmpty() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId) + .withHearingDate(LocalDate.now().plusDays(5)) + .withDurationMinutes(240) + .build())) + .build(); + + // Mock fetchCourtSchedulesByIds returning no sessions (courtscheduler could not resolve the id) + final JsonObject emptyResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder()) + .build(); + + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(emptyResponseJson); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); + // Unresolved session β‡’ day marked draft β‡’ allocation suppressed downstream. + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); } @Test @@ -1496,7 +1730,7 @@ void shouldNotCallListHearingWhenCrownUpdateMultiDaySessionsHaveDraft() { final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), true); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2))) .build(); @@ -3133,7 +3367,7 @@ void enrichCrownCourtScheduleFirst_shouldCallMultiDay_whenCourtScheduleIdPresent final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -3296,7 +3530,7 @@ void enrichCrownCourtScheduleFirst_shouldSkipListHearing_whenMultiDaySessionsHav final CourtSchedule cs3 = buildCourtSchedule(courtScheduleId3, courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -3499,7 +3733,7 @@ void enrichCrownCourtScheduleFirst_update_shouldCallMultiDay_whenCourtScheduleId final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2))) .build(); @@ -3585,7 +3819,7 @@ void enrichCrownCourtScheduleFirst_update_shouldCallMultiDay_whenRawPayloadHasCo final CourtSchedule cs3 = buildCourtSchedule(UUID.randomUUID(), courtRoomId, courtHouseId, day1.plusDays(2), false); final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() - .add("courtSchedules", JsonObjects.createArrayBuilder() + .add("sessions", JsonObjects.createArrayBuilder() .add(buildCsJson(cs1)) .add(buildCsJson(cs2)) .add(buildCsJson(cs3))) @@ -3748,7 +3982,7 @@ void enrichCrownUpdateHearing_shouldReturnUnchanged_whenMultiDayAndNoCourtSchedu } @Test - void enrichCrownUpdateHearing_shouldReturnUnchanged_whenSingleDayAndFetchReturnsEmpty() { + void enrichCrownUpdateHearing_shouldMarkDaysDraft_whenSingleDayAndFetchReturnsEmpty() { final UUID hearingId = UUID.randomUUID(); final UUID courtScheduleId = UUID.randomUUID(); @@ -3778,7 +4012,11 @@ void enrichCrownUpdateHearing_shouldReturnUnchanged_whenSingleDayAndFetchReturns final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); - assertThat(result, is(hearing)); + // Unresolved session β‡’ days marked draft so the aggregate cannot allocate on them; + // everything else (id, courtScheduleId) is preserved. + assertThat(result.getHearingId(), is(hearingId)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); verify(hearingSlotsService, never()).listHearingInCourtSessions(any()); } @@ -4123,10 +4361,13 @@ void enrichCrownUpdateHearing_shouldMergeCourtScheduleIdFromNonDefaultDaysOntoHe } @Test - void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_evenWhenNonDefaultDaysHasOne() { + void enrichCrownUpdateHearing_shouldPromoteNonDefaultDayCourtScheduleIdOntoHearingDay_evenWhenHearingDayAlreadyHasOne() { + // Reschedule case: hearingDays carries the OLD (stale) courtScheduleId from the pre-reschedule + // session; nonDefaultDays carries the NEW (authoritative) courtScheduleId returned by + // courtscheduler after the reschedule. The merge must overwrite the stale id with the new one. final UUID hearingId = UUID.randomUUID(); final UUID existingDraftCourtScheduleId = UUID.randomUUID(); - final UUID finalCourtScheduleIdOnNonDefault = UUID.randomUUID(); + final UUID newNonDraftCourtScheduleId = UUID.randomUUID(); final LocalDate hearingDate = LocalDate.now().plusDays(5); final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); @@ -4143,13 +4384,14 @@ void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_ .build())) .withNonDefaultDays(Collections.singletonList( NonDefaultDay.nonDefaultDay() - .withCourtScheduleId(finalCourtScheduleIdOnNonDefault.toString()) + .withCourtScheduleId(newNonDraftCourtScheduleId.toString()) .withStartTime(startTime) .withDuration(240) .build())) .build(); - // Mock downstream fetch to return empty so we can inspect the merged hearing + // Mock downstream fetch to return empty (simulates courtscheduler returning nothing for the + // session lookup β€” sufficient to verify the new id reached the fetch call). final JsonObject emptyResponse = JsonObjects.createObjectBuilder() .add("courtSchedules", JsonObjects.createArrayBuilder()) .build(); @@ -4160,8 +4402,98 @@ void enrichCrownUpdateHearing_shouldPreserveExistingCourtScheduleIdOnHearingDay_ final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + // The NEW id from nonDefaultDays must have been promoted onto the hearingDay. + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(newNonDraftCourtScheduleId)); + } + + @Test + void enrichCrownUpdateHearing_reschedule_shouldProduceIsDraftFalse_whenOldHearingDayHasDraftIdButNonDefaultDaysHasNonDraftId() { + // Full reschedule bug regression test: hearing was in a DRAFT session (old id); after + // reschedule courtscheduler assigns a non-draft session (new id on nonDefaultDays). + // The enriched hearingDay must carry the NEW id and isDraft=false so that + // Hearing.canAllocateForCrown() passes and hearing-allocated-for-listing-v2 is emitted. + final UUID hearingId = UUID.randomUUID(); + final UUID oldDraftCourtScheduleId = UUID.randomUUID(); + final UUID newNonDraftCourtScheduleId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(oldDraftCourtScheduleId) // stale OLD draft id + .withHearingDate(hearingDate) + .withDurationMinutes(240) + .build())) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(newNonDraftCourtScheduleId.toString()) // NEW non-draft id + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + // Courtscheduler returns the NEW non-draft session when fetched by the new id + final CourtSchedule nonDraftSession = new CourtSchedule(); + nonDraftSession.setCourtScheduleId(newNonDraftCourtScheduleId.toString()); + nonDraftSession.setSessionDate(hearingDate); + nonDraftSession.setCourtRoomId(courtRoomId.toString()); + nonDraftSession.setCourtHouseId(courtHouseId.toString()); + nonDraftSession.setDraft(false); // non-draft β€” the reschedule landed on a final session + nonDraftSession.setHearingStartTime(startTime.toString()); + + final JsonObject csResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", newNonDraftCourtScheduleId.toString()))) + .build(); + + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(csResponseJson); + when(jsonObjectConverter.convert(any(javax.json.JsonObject.class), eq(CourtSchedule.class))).thenReturn(nonDraftSession); + + // listHearingInCourtSessions is called to deduct the slot; return a minimal success response + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", newNonDraftCourtScheduleId.toString()) + .add("hearingStartTime", startTime.toString()) + .add("isDraft", false) + .add("duration", 240))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(javax.json.JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(javax.json.JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final javax.json.JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder().add(newNonDraftCourtScheduleId.toString()).build()); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + assertThat(result.getHearingDays().size(), is(1)); - assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(existingDraftCourtScheduleId)); + // The NEW non-draft id must be on the enriched hearingDay + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(newNonDraftCourtScheduleId)); + // isDraft must be false so canAllocateForCrown() passes in the aggregate + assertThat(result.getHearingDays().get(0).getIsDraft(), is(false)); } @Test @@ -4197,33 +4529,350 @@ void enrichCrownUpdateHearing_shouldSkipMerge_whenNonDefaultDayCourtScheduleIdDa assertNull(result.getHearingDays().get(0).getCourtScheduleId()); } + // ─── schedule-only courtroom derivation (derive room from resolved FINAL schedule) ─── + // A schedule-only update payload carries a courtScheduleId but NO room fields. When the id + // resolves to a FINAL (isDraft=false) session, enrichment must derive the command-level + // courtroom from the resolved schedule so the handler assigns it and canAllocateForCrown() + // opens. Draft sessions stay roomless (ADR-005); payload-supplied rooms are never overridden. + @Test - void handleCrownMultiDayExtension_rebuildsHearingDays_on200() { + void enrichCrownUpdateHearing_scheduleOnly_shouldDeriveCommandLevelCourtRoomFromResolvedFinalSession() { final UUID hearingId = UUID.randomUUID(); - final UUID firstDayCsId = UUID.randomUUID(); - final LocalDate startDate = LocalDate.of(2026, 3, 2); - final LocalDate endDate = LocalDate.of(2026, 3, 4); - - final HearingDay day1 = HearingDay.hearingDay() - .withCourtScheduleId(firstDayCsId) - .withDurationMinutes(360) - .withHearingDate(startDate) - .build(); - final HearingDay day2 = HearingDay.hearingDay() - .withDurationMinutes(360) - .withHearingDate(startDate.plusDays(1)) - .build(); - final HearingDay day3 = HearingDay.hearingDay() - .withDurationMinutes(360) - .withHearingDate(endDate) - .build(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + // Schedule-only shape: no hearingDays, no courtRoomId, no selectedCourtCentre β€” + // the courtScheduleId on nonDefaultDays is the only session reference. final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() - .withHearingId(hearingId) .withJurisdictionType(JurisdictionType.CROWN) - .withStartDate(startDate) - .withEndDate(endDate) - .withHearingDays(Arrays.asList(day1, day2, day3)) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // Day-level: room + isDraft=false from the resolved session (existing behaviour). + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getCourtScheduleId(), is(courtScheduleId)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(false)); + assertThat(result.getHearingDays().get(0).getCourtRoomId(), is(sessionCourtRoomId)); + // Command-level: the courtroom must be DERIVED from the resolved FINAL session so the + // handler assigns it (instead of removeCourtRoom) and canAllocateForCrown() opens. + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_scheduleOnly_shouldNotDeriveCommandLevelCourtRoom_whenSessionIsDraft() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + // Draft session: courtscheduler sanitises the room on every query path (ADR-005), + // so the by-id response carries NO courtRoomId. + final CourtSchedule draftSession = new CourtSchedule(); + draftSession.setCourtScheduleId(courtScheduleId.toString()); + draftSession.setCourtHouseId(courtHouseId.toString()); + draftSession.setSessionDate(hearingDate); + draftSession.setDraft(true); + draftSession.setHearingStartTime(startTime.toString()); + mockSingleDaySessionLookup(courtScheduleId, draftSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // Unallocated Crown hearings stay roomless at every level. + assertThat(result.getHearingDays().size(), is(1)); + assertThat(result.getHearingDays().get(0).getIsDraft(), is(true)); + assertNull(result.getHearingDays().get(0).getCourtRoomId()); + assertNull(result.getCourtRoomId()); + } + + @Test + void enrichCrownUpdateHearing_shouldPreservePayloadCourtRoom_whenCommandAlreadyCarriesOne() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID payloadCourtRoomId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + // UI-parity shape: the payload already carries the room at command level. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withCourtRoomId(payloadCourtRoomId) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + // A payload-supplied room is never overridden by the derivation. + assertThat(result.getCourtRoomId(), is(payloadCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_scheduleOnly_shouldBackfillRoomlessSelectedCourtCentre_fromResolvedFinalSession() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID selectedCourtCentreId = UUID.randomUUID(); + final LocalDate hearingDate = LocalDate.now().plusDays(5); + final ZonedDateTime startTime = hearingDate.atStartOfDay(ZoneOffset.UTC); + + // selectedCourtCentre present but ROOMLESS: for CROWN the handler prefers + // selectedCourtCentre.courtRoomId over the top-level field, so the derivation must + // backfill it too or the handler would still resolve a null room. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingId(hearingId) + .withStartDate(hearingDate) + .withEndDate(hearingDate) + .withSelectedCourtCentre(SelectedCourtCentre.selectedCourtCentre() + .withId(selectedCourtCentreId) + .withOuCode("OU123") + .build()) + .withNonDefaultDays(Collections.singletonList( + NonDefaultDay.nonDefaultDay() + .withCourtScheduleId(courtScheduleId.toString()) + .withStartTime(startTime) + .withDuration(240) + .build())) + .build(); + + final CourtSchedule finalSession = buildCourtSchedule(courtScheduleId, sessionCourtRoomId, courtHouseId, hearingDate, false); + mockSingleDaySessionLookup(courtScheduleId, finalSession); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + assertThat(result.getSelectedCourtCentre().getCourtRoomId(), is(sessionCourtRoomId)); + // The rest of selectedCourtCentre is preserved. + assertThat(result.getSelectedCourtCentre().getId(), is(selectedCourtCentreId)); + assertThat(result.getSelectedCourtCentre().getOuCode(), is("OU123")); + } + + @Test + void enrichCrownUpdateHearing_multiDay_shouldDeriveCommandLevelRoom_whenAllSessionsFinalAndSameRoom() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID sessionCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + // Multi-day (total > 360), no room anywhere in the payload. + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(360) + .build(), + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId2) + .withHearingDate(day1.plusDays(1)) + .withDurationMinutes(360) + .build())) + .build(); + + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, sessionCourtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, sessionCourtRoomId, courtHouseId, day1.plusDays(1), false); + mockMultiDaySearchAndBook(courtScheduleId1, courtScheduleId2, cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + // Every booked session is FINAL and sits in the SAME room β€” that room becomes the + // command-level courtroom. + assertThat(result.getCourtRoomId(), is(sessionCourtRoomId)); + } + + @Test + void enrichCrownUpdateHearing_multiDay_shouldNotDeriveCommandLevelRoom_whenSessionsSpanDifferentRooms() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(360) + .build(), + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId2) + .withHearingDate(day1.plusDays(1)) + .withDurationMinutes(360) + .build())) + .build(); + + // Two FINAL sessions in DIFFERENT rooms: ambiguous β€” no command-level room is derived. + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, UUID.randomUUID(), courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, UUID.randomUUID(), courtHouseId, day1.plusDays(1), false); + mockMultiDaySearchAndBook(courtScheduleId1, courtScheduleId2, cs1, cs2); + + final UpdateHearingForListing result = courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + assertNull(result.getCourtRoomId()); + } + + /** + * Mocks the single-day CROWN update chain: GET court-schedules-by-id returning the given + * session, plus the listHearingInCourtSessions slot-deduction call. + */ + private void mockSingleDaySessionLookup(final UUID courtScheduleId, final CourtSchedule session) { + final JsonObject csResponseJson = JsonObjects.createObjectBuilder() + .add("courtSchedules", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", courtScheduleId.toString()))) + .build(); + final Response csResponse = mock(Response.class); + when(csResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.getCourtSchedulesById(anyMap())).thenReturn(csResponse); + when(objectToJsonObjectConverter.convert(csResponse.getEntity())).thenReturn(csResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(session); + + mockListHearingInCourtSessions(courtScheduleId); + } + + /** + * Mocks the multi-day CROWN update chain: multiDaySearchAndBook returning the two given + * sessions, plus the listHearingInCourtSessions slot-deduction call. + */ + private void mockMultiDaySearchAndBook(final UUID courtScheduleId1, final UUID courtScheduleId2, + final CourtSchedule cs1, final CourtSchedule cs2) { + final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)) + .add(buildCsJson(cs2))) + .build(); + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(multiDayResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))).thenReturn(cs1, cs2); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId1, "2026-03-16T10:00:00Z", 360)) + .add(buildListHearingJson(courtScheduleId2, "2026-03-17T10:00:00Z", 360))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder() + .add(courtScheduleId1.toString()) + .add(courtScheduleId2.toString()) + .build()); + } + + private void mockListHearingInCourtSessions(final UUID courtScheduleId) { + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId, "2026-03-16T10:00:00Z", 240))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + final JsonObject jo = inv.getArgument(0); + final ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder().add(courtScheduleId.toString()).build()); + } + + @Test + void handleCrownMultiDayExtension_rebuildsHearingDays_on200() { + final UUID hearingId = UUID.randomUUID(); + final UUID firstDayCsId = UUID.randomUUID(); + final LocalDate startDate = LocalDate.of(2026, 3, 2); + final LocalDate endDate = LocalDate.of(2026, 3, 4); + + final HearingDay day1 = HearingDay.hearingDay() + .withCourtScheduleId(firstDayCsId) + .withDurationMinutes(360) + .withHearingDate(startDate) + .build(); + final HearingDay day2 = HearingDay.hearingDay() + .withDurationMinutes(360) + .withHearingDate(startDate.plusDays(1)) + .build(); + final HearingDay day3 = HearingDay.hearingDay() + .withDurationMinutes(360) + .withHearingDate(endDate) + .build(); + + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.CROWN) + .withStartDate(startDate) + .withEndDate(endDate) + .withHearingDays(Arrays.asList(day1, day2, day3)) .build(); final String returnedCsId1 = UUID.randomUUID().toString(); @@ -4462,7 +5111,7 @@ void handleCrownMultiDayExtension_throws_whenResponseHasNoEntity() { } @Test - public void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAllocatedBookedSlot() { + void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAllocatedBookedSlot() { final UUID bookingReference = UUID.randomUUID(); final UUID courtHouseId = UUID.randomUUID(); final UUID courtRoomId = UUID.randomUUID(); @@ -4505,7 +5154,7 @@ public void promoteCrownBookingReferenceToBookedSlot_resolvesSessionAndBuildsAll } @Test - public void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { + void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { final UUID bookingReference = UUID.randomUUID(); final UUID courtHouseId = UUID.randomUUID(); final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() @@ -4541,7 +5190,7 @@ public void promoteCrownBookingReferenceToBookedSlot_draftSessionOmitsRoom() { } @Test - public void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolvable() { + void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolvable() { final UUID bookingReference = UUID.randomUUID(); final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) @@ -4573,7 +5222,7 @@ public void promoteCrownBookingReferenceToBookedSlot_skipsWhenNoStartTimeResolva } @Test - public void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceDoesNotResolve() { + void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceDoesNotResolve() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) @@ -4595,7 +5244,7 @@ public void promoteCrownBookingReferenceToBookedSlot_throwsWhenBookingReferenceD } @Test - public void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference() { + void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) @@ -4609,7 +5258,7 @@ public void promoteCrownBookingReferenceToBookedSlot_noOpWhenNoBookingReference( } @Test - public void promoteCrownBookingReferenceToBookedSlot_noOpWhenBookedSlotAlreadyHasCourtScheduleId() { + void promoteCrownBookingReferenceToBookedSlot_noOpWhenBookedSlotAlreadyHasCourtScheduleId() { final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() .withId(UUID.randomUUID()) .withJurisdictionType(JurisdictionType.CROWN) @@ -5235,4 +5884,346 @@ void shouldNotAdjustCourtCentreWhenFirstEnrichedHearingDayHasNullCourtRoomId() { // Court centre should NOT have been updated β€” the enriched day has no courtRoomId assertThat(result.getCourtCentre().getRoomId(), is(existingRoomId)); } + + // ─── searchAndBookSlots coverage tests ────────────────────────────── + + @Test + void searchAndBookShouldReturnNullWhenSessionsArrayIsEmpty() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = LocalDate.now().toString(); + final Integer durationInMinutes = 20; + + // sessions[] is empty β€” should return null + final javax.json.JsonObject emptySessionsResponse = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder()) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(emptySessionsResponse); + when(objectToJsonObjectConverter.convert(any())).thenReturn(emptySessionsResponse); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertNull(result); + } + + @Test + void searchAndBookShouldReturnNullWhenResponseIsNotOk() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = LocalDate.now().toString(); + final Integer durationInMinutes = 20; + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertNull(result); + } + + @Test + void searchAndBookShouldReadDraftFieldFromSessionsArray() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // "draft" field (Jackson strips "is" prefix) should be used when present + final javax.json.JsonObject responseWithDraftField = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", true))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithDraftField); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithDraftField); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertThat(result, not(nullValue())); + assertTrue(result.isDraft()); + } + + @Test + void searchAndBookShouldReadIsDraftFieldWhenDraftFieldAbsent() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // "isDraft" field used as fallback when "draft" is absent + final javax.json.JsonObject responseWithIsDraftField = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("isDraft", false))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithIsDraftField); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithIsDraftField); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, false); + + assertThat(result, not(nullValue())); + assertThat(result.isDraft(), is(false)); + } + + @Test + void searchAndBookShouldExtractJudiciariesFromSession() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + final String judicialId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + final javax.json.JsonObject responseWithJudiciary = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", false) + .add("judiciaries", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("judiciaryId", judicialId) + .add("judiciaryType", "DISTRICT_JUDGE") + .add("deputy", false) + .add("benchChairman", true))))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseWithJudiciary); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseWithJudiciary); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, true); + + assertThat(result, not(nullValue())); + assertThat(result.judiciaries().size(), is(1)); + assertThat(result.judiciaries().get(0).getJudicialId().toString(), is(judicialId)); + } + + @Test + void searchAndBookShouldReturnEmptyJudiciariesWhenAbsent() { + final String hearingId = "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c"; + final String ouCode = "OU12345"; + final String hearingSessionDate = LocalDate.now().toString(); + final String courtRoomId = UUID.randomUUID().toString(); + final String hearingSessionDateSearchCutOff = LocalDate.now().plusDays(7).toString(); + final String sessionStartTime = "2020-05-26T09:00:00Z"; + final Integer durationInMinutes = 20; + + // No judiciaries key in session + final javax.json.JsonObject responseNoJudiciaries = javax.json.Json.createObjectBuilder() + .add("hearingId", hearingId) + .add("sessions", javax.json.Json.createArrayBuilder() + .add(javax.json.Json.createObjectBuilder() + .add("courtScheduleId", "23681024-8eac-4890-8c44-4651ad48cb24") + .add("courtRoomId", courtRoomId) + .add("sessionStartTime", sessionStartTime) + .add("draft", false))) + .build(); + + when(hearingSlotsService.searchBookSlots(anyMap())).thenReturn(response); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.getEntity()).thenReturn(responseNoJudiciaries); + when(objectToJsonObjectConverter.convert(any())).thenReturn(responseNoJudiciaries); + + final uk.gov.moj.cpp.listing.domain.HearingSlotSearchResponse result = courtScheduleEnrichmentService + .searchAndBookSlots(hearingId, ouCode, hearingSessionDate, courtRoomId, hearingSessionDateSearchCutOff, sessionStartTime, durationInMinutes, false); + + assertThat(result, not(nullValue())); + assertThat(result.judiciaries().size(), is(0)); + } + + // ─── getUpdateSlotsPayload key regression guard ───────────────────────── + + @Test + void getUpdateSlotsPayloadShouldUseCourtScheduleIdsKeyNotIds() { + // Verify that the body sent to list.hearings-in-sessions uses "courtScheduleIds" + // (not the old "ids" key that caused real-env 400s). + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + + final HearingDay hd = HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId) + .withHearingDate(LocalDate.now()) + .withDurationMinutes(30) + .build(); + + final UpdateHearingForListing update = UpdateHearingForListing.updateHearingForListing() + .withHearingId(hearingId) + .withJurisdictionType(JurisdictionType.MAGISTRATES) + .withSelectedCourtCentre(SelectedCourtCentre.selectedCourtCentre().withOuCode("OU1").build()) + .withHearingDays(Collections.singletonList(hd)) + .build(); + + final javax.json.JsonArray courtScheduleIdsArray = + JsonObjects.createArrayBuilder().add(courtScheduleId.toString()).build(); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(courtScheduleIdsArray); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(JsonObjects.createObjectBuilder() + .add("courtScheduleId", courtScheduleId.toString()) + .add("hearingStartTime", "2026-01-01T09:00:00Z") + .add("duration", 30))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + JsonObject jo = inv.getArgument(0); + ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + + final org.mockito.ArgumentCaptor payloadCaptor = + org.mockito.ArgumentCaptor.forClass(JsonObject.class); + + courtScheduleEnrichmentService.enrichWithCourtSchedules(update, mock(JsonEnvelope.class)); + + verify(hearingSlotsService).listHearingInCourtSessions(payloadCaptor.capture()); + final JsonObject captured = payloadCaptor.getValue(); + final javax.json.JsonArray hearingSlots = captured.getJsonArray("hearingSlots"); + assertThat("hearingSlots array must be present in payload", hearingSlots != null, is(true)); + assertThat("hearingSlots must be non-empty", hearingSlots.isEmpty(), is(false)); + final JsonObject slot0 = hearingSlots.getJsonObject(0); + assertTrue(slot0.containsKey("courtScheduleIds"), + "hearingSlots[0] must use key 'courtScheduleIds' not 'ids'"); + } + + // ─── multiDaySearchAndBook courtCentreId + hearingDate guard ──────────── + + @Test + void multiDaySearchAndBookShouldIncludeCourtCentreIdAndHearingDateInParams() { + // Guard against crown.search.and.book schema violations: courtCentreId and hearingDate + // are required by the schema (additionalProperties:false) and must be present in the body. + final UUID hearingId = UUID.randomUUID(); + final UUID courtScheduleId1 = UUID.randomUUID(); + final UUID courtScheduleId2 = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final LocalDate day1 = LocalDate.now().plusDays(5); + + final HearingListingNeeds hearing = HearingListingNeeds.hearingListingNeeds() + .withJurisdictionType(JurisdictionType.CROWN) + .withId(hearingId) + .withEstimatedMinutes(720) + .withCourtCentre(CourtCentre.courtCentre().withId(courtHouseId).withRoomId(courtRoomId).build()) + .withHearingDays(Collections.singletonList( + HearingDay.hearingDay() + .withCourtScheduleId(courtScheduleId1) + .withHearingDate(day1) + .withDurationMinutes(720) + .build())) + .withBookedSlots(Collections.singletonList( + RotaSlot.rotaSlot() + .withCourtScheduleId(courtScheduleId1.toString()) + .withCourtCentreId(courtHouseId.toString()) + .withStartTime(day1.atStartOfDay(ZoneOffset.UTC)) + .build())) + .build(); + + final CourtSchedule cs1 = buildCourtSchedule(courtScheduleId1, courtRoomId, courtHouseId, day1, false); + final CourtSchedule cs2 = buildCourtSchedule(courtScheduleId2, courtRoomId, courtHouseId, day1.plusDays(1), false); + + final JsonObject multiDayResponseJson = JsonObjects.createObjectBuilder() + .add("sessions", JsonObjects.createArrayBuilder() + .add(buildCsJson(cs1)) + .add(buildCsJson(cs2))) + .build(); + + final Response multiDayResponse = mock(Response.class); + when(multiDayResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(hearingSlotsService.multiDaySearchAndBook(anyMap())).thenReturn(multiDayResponse); + when(objectToJsonObjectConverter.convert(multiDayResponse.getEntity())).thenReturn(multiDayResponseJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(CourtSchedule.class))) + .thenReturn(cs1, cs2); + + final JsonObject listJson = JsonObjects.createObjectBuilder() + .add("hearings", JsonObjects.createArrayBuilder() + .add(buildListHearingJson(courtScheduleId1, "2026-03-16T10:00:00Z", 360)) + .add(buildListHearingJson(courtScheduleId2, "2026-03-17T10:00:00Z", 360))) + .build(); + final Response listResponse = mock(Response.class); + when(listResponse.getStatus()).thenReturn(HttpStatus.SC_OK); + when(listResponse.getEntity()).thenReturn(listJson); + when(hearingSlotsService.listHearingInCourtSessions(any(JsonObject.class))).thenReturn(listResponse); + when(objectToJsonObjectConverter.convert(listJson)).thenReturn(listJson); + when(jsonObjectConverter.convert(any(JsonObject.class), eq(ListUpdateHearing.class))) + .thenAnswer(inv -> { + JsonObject jo = inv.getArgument(0); + ListUpdateHearing luh = new ListUpdateHearing(); + luh.setCourtScheduleId(jo.getString("courtScheduleId")); + luh.setHearingStartTime(jo.getString("hearingStartTime")); + luh.setDuration(jo.getInt("duration")); + return luh; + }); + when(slotsToJsonStringConverter.convertHearingDaysToCourtScheduleIdsJson(anyList())) + .thenReturn(JsonObjects.createArrayBuilder() + .add(courtScheduleId1.toString()) + .add(courtScheduleId2.toString()) + .build()); + + @SuppressWarnings("unchecked") + final org.mockito.ArgumentCaptor> mapCaptor = + org.mockito.ArgumentCaptor.forClass(java.util.Map.class); + + courtScheduleEnrichmentService.enrichWithCourtSchedules(hearing, mock(JsonEnvelope.class)); + + verify(hearingSlotsService).multiDaySearchAndBook(mapCaptor.capture()); + final java.util.Map params = mapCaptor.getValue(); + assertTrue(params.containsKey("courtCentreId"), + "multiDaySearchAndBook params must contain 'courtCentreId' (required by crown.search.and.book schema)"); + assertTrue(params.containsKey("hearingDate"), + "multiDaySearchAndBook params must contain 'hearingDate' (required by crown.search.and.book schema)"); + assertThat("courtCentreId must be non-null", params.get("courtCentreId") != null, is(true)); + assertThat("hearingDate must be non-null", params.get("hearingDate") != null, is(true)); + assertThat("courtCentreId should be the court house UUID", params.get("courtCentreId"), is(courtHouseId.toString())); + assertThat("hearingDate should be the anchor day's date", params.get("hearingDate"), is(day1.toString())); + } } diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java new file mode 100644 index 000000000..59642ba16 --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/CrownNonDefaultDaysValidatorTest.java @@ -0,0 +1,154 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import uk.gov.justice.core.courts.JurisdictionType; +import uk.gov.justice.listing.commands.NonDefaultDay; +import uk.gov.justice.listing.commands.UpdateHearingForListing; +import uk.gov.justice.services.adapter.rest.exception.BadRequestException; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class CrownNonDefaultDaysValidatorTest { + + private static final LocalDate START_DATE = LocalDate.now().plusDays(10); + private static final LocalDate END_DATE = START_DATE.plusDays(3); + + @Test + void shouldRejectMoreThanOneBlockDescriptorVirtualDay() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + virtualDay(START_DATE.plusDays(1), 720))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("at most one virtual nonDefaultDay may carry the block total")); + } + + @Test + void shouldAcceptMultiplePerDayVirtualProxies() { + // Court-room-change shape (HearingDayCourtRoomChangeForCrownIT): N virtual days each ≀ one + // court day, dates inside the window but NOT on startDate β€” must stay accepted. + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE.plusDays(2), 360), + virtualDay(START_DATE.plusDays(3), 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptGenuineDayOutsideWindowWhenNoBlockDescriptor() { + // Legacy shape (HearingIT.updateHearingResultsWhenCourtRoomNotSelected): a stale genuine + // day outside the window with no block descriptor relies on the silent enrichment filter. + final UpdateHearingForListing hearing = crownUpdate(Collections.singletonList( + genuineDay(START_DATE.minusYears(6), 15))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldRejectWhenStartDateDiffersFromVirtualDayDate() { + final UpdateHearingForListing hearing = crownUpdate(Collections.singletonList( + virtualDay(START_DATE.plusDays(1), 1440))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("must equal the virtual nonDefaultDay's date")); + } + + @Test + void shouldRejectGenuineNonDefaultDayOutsideWindow() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(END_DATE.plusDays(1), 360))); + + final BadRequestException e = assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + assertThat(e.getMessage(), containsString("must fall within startDate")); + } + + @Test + void shouldRejectGenuineNonDefaultDayBeforeWindowWhenBlockDescriptorPresent() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE.minusDays(1), 360))); + + assertThrows(BadRequestException.class, + () -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptValidMixedPayload() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE.plusDays(2), 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptGenuineDaysOnWindowBoundaries() { + final UpdateHearingForListing hearing = crownUpdate(Arrays.asList( + virtualDay(START_DATE, 1440), + genuineDay(START_DATE, 360), + genuineDay(END_DATE, 360))); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + @Test + void shouldAcceptWhenNoNonDefaultDays() { + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(crownUpdate(null))); + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(crownUpdate(Collections.emptyList()))); + } + + @Test + void shouldSkipDateRulesWhenStartDateAbsent() { + // weekCommencing shape: no startDate/endDate on the command β€” only the uniqueness rule applies. + final UpdateHearingForListing hearing = UpdateHearingForListing.updateHearingForListing() + .withHearingId(UUID.randomUUID()) + .withJurisdictionType(JurisdictionType.CROWN) + .withNonDefaultDays(Arrays.asList( + virtualDay(START_DATE.plusDays(5), 1440), + genuineDay(START_DATE.plusDays(20), 360))) + .build(); + + assertDoesNotThrow(() -> CrownNonDefaultDaysValidator.validateForCrownUpdate(hearing)); + } + + private static UpdateHearingForListing crownUpdate(final List nonDefaultDays) { + return UpdateHearingForListing.updateHearingForListing() + .withHearingId(UUID.randomUUID()) + .withJurisdictionType(JurisdictionType.CROWN) + .withStartDate(START_DATE) + .withEndDate(END_DATE) + .withNonDefaultDays(nonDefaultDays) + .build(); + } + + private static NonDefaultDay virtualDay(final LocalDate date, final int duration) { + return NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(date + "T09:00:00Z")) + .withDuration(duration) + .withCourtScheduleId(UUID.randomUUID().toString()) + .withVirtual(Boolean.TRUE) + .build(); + } + + private static NonDefaultDay genuineDay(final LocalDate date, final int duration) { + return NonDefaultDay.nonDefaultDay() + .withStartTime(ZonedDateTime.parse(date + "T09:00:00Z")) + .withDuration(duration) + .build(); + } +} diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java new file mode 100644 index 000000000..b540ac3f4 --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java @@ -0,0 +1,75 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static java.util.UUID.randomUUID; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static uk.gov.justice.services.test.utils.core.enveloper.EnveloperFactory.createEnveloper; +import static uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory.metadataWithRandomUUIDAndName; + +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.messaging.JsonEnvelope; +import uk.gov.moj.cpp.listing.query.view.HearingQueryView; + +import java.util.Optional; +import java.util.UUID; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.ws.rs.NotFoundException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HearingLookupServiceTest { + + @Spy + private final Enveloper enveloper = createEnveloper(); + + @Mock + private HearingQueryView hearingQueryView; + + @Mock + private JsonEnvelope envelope; + + @InjectMocks + private HearingLookupService hearingLookupService; + + @Test + void shouldReturnHearingWhenFound() { + final UUID hearingId = randomUUID(); + final JsonObject hearingPayload = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenReturn(JsonEnvelope.envelopeFrom(metadataWithRandomUUIDAndName().build(), hearingPayload)); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isPresent()); + assertThat(result.get().getString("jurisdictionType"), is("MAGISTRATES")); + } + + @Test + void shouldReturnEmptyWhenHearingNotFound() { + final UUID hearingId = randomUUID(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenThrow(new NotFoundException("no hearing")); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isEmpty()); + } +} diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json index 885436da4..f5b0f1ae6 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.draft.json @@ -1,10 +1,13 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": true - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": true, + "businessType": "TRIAL", + "judiciaries": [] + } + ] } diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json index fe96d6abc..dc8752a69 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.json @@ -1,10 +1,13 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": false - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": false, + "businessType": "TRIAL", + "judiciaries": [] + } + ] } diff --git a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json index 1f64e72d0..8045f24b2 100644 --- a/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json +++ b/listing-command/listing-command-api/src/test/resources/courtscheduler.search.book.hearing.slots.with.judiciaries.json @@ -1,24 +1,26 @@ { - "hearingSlots": { - "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", - "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", - "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", - "hearingStartTime": "2020-05-26T09:00:000Z", - "duration": 20, - "isDraft": false, - "judiciaries": [ - { - "judiciaryId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "judiciaryType": "CIRCUIT_JUDGE", - "benchChairman": true, - "deputy": false - }, - { - "judiciaryId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "judiciaryType": "RECORDER", - "benchChairman": false, - "deputy": true - } - ] - } + "hearingId": "5416c10a-0cf1-49d5-a7c9-5761ff3bdf2c", + "sessions": [ + { + "courtScheduleId": "23681024-8eac-4890-8c44-4651ad48cb24", + "courtRoomId": "573bd1e6-92fa-49c2-8fa9-a355c1a4cded", + "sessionStartTime": "2020-05-26T09:00:000Z", + "draft": false, + "businessType": "TRIAL", + "judiciaries": [ + { + "judiciaryId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "judiciaryType": "CIRCUIT_JUDGE", + "benchChairman": true, + "deputy": false + }, + { + "judiciaryId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "judiciaryType": "RECORDER", + "benchChairman": false, + "deputy": true + } + ] + } + ] } diff --git a/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json new file mode 100644 index 000000000..2f33b700e --- /dev/null +++ b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json @@ -0,0 +1,6 @@ +{ +"object":"Change hearing to past date", +"action":"Link", +"key":"Change hearing to past date_Link", +"keyWithOutSource":"Change hearing to past date_Link" +} diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index b49b4ddb2..aab8c1cb6 100644 --- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java +++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java @@ -192,6 +192,16 @@ public class ListingCommandHandler { private static final String HEARINGS = "hearings"; private static final String PROSECUTION_CASE = "prosecutionCase"; public static final String OUCODE = "oucode"; + private static final String JURISDICTION = "jurisdiction"; + private static final String START_DATE = "startDate"; + private static final String COURT_SCHEDULE_ID = "courtScheduleId"; + private static final String SESSION_DATE = "sessionDate"; + private static final String MOVE_COURT_CENTRE_ID = "courtCentreId"; + private static final String MOVE_COURT_ROOM_ID = "courtRoomId"; + private static final String SESSION_START_TIME = "sessionStartTime"; + private static final String SESSION_END_TIME = "sessionEndTime"; + private static final String DURATION_IN_MINUTES = "durationInMinutes"; + private static final String CROWN_JURISDICTION = "CROWN"; @Inject private EventSource eventSource; @@ -412,6 +422,73 @@ public void vacateTrial(final JsonEnvelope command) throws EventStreamException updateHearingEventStream(command, vacateTrialEnriched.getHearingId(), (Hearing hearing) -> hearing.vacateTrial(vacateTrialEnriched.getHearingId(), vacateTrialEnriched.getVacatedTrialReasonId())); } + @Handles("listing.command.move-hearing-to-past-date-enriched") + public void moveHearingToPastDate(final JsonEnvelope command) throws EventStreamException { + + LOGGER.info("'listing.command.move-hearing-to-past-date-enriched' received with payload {}", command.toObfuscatedDebugString()); + + final JsonObject payload = command.payloadAsJsonObject(); + final UUID hearingId = fromString(payload.getString(HEARING_ID)); + final String jurisdiction = payload.getString(JURISDICTION); + final LocalDate startDate = parse(payload.getString(START_DATE)); + + // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot + // move a day to a new date. Both paths re-issue the single day on the past date instead: + // MAGS carries the slot booked by courtscheduler, CROWN carries the hearing's own existing + // room/time (enriched by command-api from its current first day - courtscheduler is never + // called for CROWN before Phase 2, Baris decision D1). + if (CROWN_JURISDICTION.equals(jurisdiction)) { + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, startDate, Optional.empty()); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.CROWN, emptyList()))); + } else { + final LocalDate sessionDate = parse(payload.getString(SESSION_DATE)); + final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, sessionDate, Optional.of(courtScheduleId)); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES, emptyList()))); + } + } + + private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(final JsonObject payload, + final LocalDate dayDate, + final Optional courtScheduleId) { + final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); + final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) + : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); + final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) + ? payload.getInt(DURATION_IN_MINUTES) : null; + // hearing-days-changed-for-hearing requires endTime on every day; the normal listing flows + // always compute it as startTime + duration, so mirror that when the payload has no end time. + final ZonedDateTime dayEndTime; + if (payload.containsKey(SESSION_END_TIME)) { + dayEndTime = ZonedDateTime.parse(payload.getString(SESSION_END_TIME)); + } else if (durationInMinutes != null) { + dayEndTime = dayStartTime.plusMinutes(durationInMinutes); + } else { + dayEndTime = dayStartTime; + } + + return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() + .withHearingDate(dayDate) + .withStartTime(dayStartTime) + .withEndTime(dayEndTime) + .withDurationMinutes(durationInMinutes) + .withSequence(1) + .withCourtScheduleId(courtScheduleId) + .withCourtCentreId(courtCentreId) + .withCourtRoomId(courtRoomId) + .build(); + } + @Handles("listing.command.hearing-vacate-trial") public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException { LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString()); diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..dca55cc54 --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,12 @@ +{ + "hearingId": "0baecac5-222b-402d-9047-84803679edae", + "jurisdiction": "MAGISTRATES", + "startDate": "2026-05-01", + "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", + "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", + "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153", + "sessionDate": "2026-05-01", + "sessionStartTime": "2026-05-01T09:00:00Z", + "sessionEndTime": "2026-05-01T17:00:00Z", + "durationInMinutes": 30 +} diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..7de8fda09 --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date-enriched.json", + "type": "object", + "properties": { + "hearingId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "jurisdiction": { + "type": "string", + "enum": ["CROWN", "MAGISTRATES"] + }, + "startDate": { + "type": "string", + "format": "date" + }, + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtScheduleId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtRoomId": { + "type": "string" + }, + "sessionDate": { + "type": "string", + "format": "date" + }, + "sessionStartTime": { + "type": "string" + }, + "sessionEndTime": { + "type": "string" + }, + "durationInMinutes": { + "type": "integer" + } + }, + "required": [ + "hearingId", + "jurisdiction", + "startDate", + "courtCentreId" + ], + "additionalProperties": false +} diff --git a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml index b400b48b1..988287e84 100644 --- a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml +++ b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml @@ -193,6 +193,10 @@ baseUri: message://command/handler/message/listing example: !include json/listing.command.vacate-trial-enriched.json schema: !include json/schema/listing.command.vacate-trial-enriched.json + application/vnd.listing.command.move-hearing-to-past-date-enriched+json: + example: !include json/listing.command.move-hearing-to-past-date-enriched.json + schema: !include json/schema/listing.command.move-hearing-to-past-date-enriched.json + application/vnd.listing.command.hearing-vacate-trial+json: example: !include json/listing.command.hearing-vacate-trial.json schema: !include json/schema/listing.command.hearing-vacate-trial.json diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index 80072e0b0..19f7c1494 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.any; import static org.mockito.Mockito.atLeast; @@ -99,6 +100,7 @@ import uk.gov.justice.listing.events.CaseEjected; import uk.gov.justice.listing.events.CaseIdentifierUpdated; import uk.gov.justice.listing.events.CasesAddedToHearing; +import uk.gov.justice.listing.events.HearingDayCourtSchedule; import uk.gov.justice.listing.events.CourtApplicationAddedToHearing; import uk.gov.justice.listing.events.CourtApplicationToBeUpdated; import uk.gov.justice.listing.events.CourtListRestricted; @@ -2549,6 +2551,54 @@ public void listingCommandHandlerShouldVacateTrial() throws Exception { verify(hearing, times(1)).vacateTrial(HEARING_ID_1, REASON); } + @Test + public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws Exception { + final UUID courtScheduleId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveHearingToPastDate(courtScheduleId, "2026-05-01"); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.changeStartDate(eq(LocalDate.parse("2026-05-01")), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList()))).thenReturn(Stream.empty()); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).changeStartDate(LocalDate.parse("2026-05-01"), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList())); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getCourtScheduleId().orElse(null), is(courtScheduleId)); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse("2026-05-01"))); + } + + @Test + public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception { + final String startDate = "2026-05-01"; + final UUID crownRoomId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate, crownRoomId); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.changeStartDate(eq(LocalDate.parse(startDate)), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList()))).thenReturn(Stream.empty()); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList())); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse(startDate))); + assertThat(movedDay.getCourtScheduleId().isPresent(), is(false)); + assertThat(movedDay.getCourtRoomId().orElse(null), is(crownRoomId)); + } + @Test public void listingCommandHandlerShouldHearingVacateTrial() throws Exception { final JsonEnvelope commandEnvelope = getEnvelopeForHearingVacateTrial(REASON); @@ -2739,6 +2789,22 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) { return createEnvelope("listing.command.vacate-trial-enriched", jsonReader.readObject()); } + private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"" + + sessionDate + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtScheduleId\":\"" + courtScheduleId + + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + + private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate, final UUID courtRoomId) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtRoomId\":\"" + courtRoomId + + "\",\"sessionDate\":\"" + startDate + "\",\"sessionStartTime\":\"" + startDate + "T10:00:00Z\",\"durationInMinutes\":25}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + private JsonEnvelope getEnvelopeForHearingVacateTrial(final UUID reason) { final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"vacatedTrialReasonId\":\"" + reason + "\"}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java new file mode 100644 index 000000000..852a60f49 --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java @@ -0,0 +1,40 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import static uk.gov.justice.services.messaging.JsonObjects.getString; + +import javax.json.JsonObject; + +/** + * Raised when courtscheduler rejects a move-hearing-to-past-date request (422/404), or when the + * listing side rejects the request before ever calling courtscheduler (unknown hearingId, future + * date on the CROWN listing-side path). Carries the upstream HTTP status and body so the + * {@code MoveHearingToPastDateExceptionMapper} can render an equivalent response back to the + * caller. + */ +public class MoveHearingToPastDateException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int httpStatus; + private final transient JsonObject responseBody; + private final String errorCode; + + public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) { + super(message); + this.httpStatus = httpStatus; + this.responseBody = responseBody; + this.errorCode = responseBody == null ? null : getString(responseBody, "errorCode").orElse(null); + } + + public int getHttpStatus() { + return httpStatus; + } + + public JsonObject getResponseBody() { + return responseBody; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java new file mode 100644 index 000000000..6d35209ed --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java @@ -0,0 +1,18 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Purpose-built result of a successful {@code courtscheduler.move-hearing-to-past-date} call for + * the MAGISTRATES path. Deliberately narrow β€” unlike the ccsph2n-only {@code CrownFallbackResult} + * this branch does not carry any crown-fallback/search-and-book concerns, only the slot details + * needed to enrich {@code listing.command.move-hearing-to-past-date-enriched}. + */ +public record MoveHearingToPastDateResult(UUID courtScheduleId, + String courtRoomId, + LocalDate sessionDate, + String sessionStartTime, + String sessionEndTime, + Integer durationInMinutes) { +} diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java index 1e50ed164..a227d5639 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java @@ -9,6 +9,8 @@ import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackNoSessionException; import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackResult; import uk.gov.moj.cpp.listing.common.crownfallback.CrownFallbackSource; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; import uk.gov.moj.cpp.listing.domain.JudicialRole; import uk.gov.moj.cpp.listing.domain.JudicialRoleType; @@ -26,8 +28,10 @@ import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; import javax.ws.rs.core.Response; import org.apache.commons.collections.CollectionUtils; @@ -67,6 +71,13 @@ public class CourtSchedulerServiceAdapter { private static final String DRAFT = "draft"; private static final String OVERBOOKED = "overbooked"; private static final String ANY_DRAFT = "anyDraft"; + // move-hearing-to-past-date (MAGS) wire-field constants; JURISDICTION reuses the field declared above + private static final String START_DATE = "startDate"; + private static final String MAGISTRATES_JURISDICTION = "MAGISTRATES"; + // no-session normalisation (422 NO_SESSION_FOUND) constants + public static final String NO_SESSION_FOUND = "NO_SESSION_FOUND"; + private static final String ERROR_CODE = "errorCode"; + private static final String MESSAGE = "message"; @Inject private HearingSlotsService hearingSlotsService; @Inject @@ -337,7 +348,7 @@ public JsonObject getCourtScheduleDraftStatus(final JsonObject requestPayload) { } final Map params = new HashMap<>(); - params.put("courtScheduleIds", String.join(",", courtScheduleIds)); + params.put("ids", String.join(",", courtScheduleIds)); final Response response; try { @@ -472,4 +483,62 @@ HearingIdsResponse getHearingIds(final Response response) { return new HearingIdsResponse(uuids, results, pageCount); } + + /** + * MAGISTRATES-only. Calls courtscheduler's {@code move-hearing-to-past-date} action + * synchronously. CROWN moves are handled entirely listing-side and never reach this method + * (Baris decision D1). On any non-200 response the upstream errorCode/status is surfaced via + * {@link MoveHearingToPastDateException} so the caller sends no event. + */ + public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, + final UUID courtCentreId, + final LocalDate startDate, + final Integer durationInMinutes) { + // hearingId travels only in the URL path; courtscheduler's REST adapter injects it + final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() + .add(COURT_CENTRE_ID, courtCentreId.toString()) + .add(JURISDICTION, MAGISTRATES_JURISDICTION) + .add(START_DATE, startDate.toString()); + if (durationInMinutes != null) { + requestBuilder.add(DURATION_IN_MINUTES, durationInMinutes); + } + + final Response response = hearingSlotsService.moveHearingToPastDate(hearingId, requestBuilder.build()); + final int status = response.getStatus(); + final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject) + ? (JsonObject) response.getEntity() + : Json.createObjectBuilder().build(); + + if (HttpStatus.SC_OK == status) { + return parseMoveHearingToPastDateResult(body); + } + + LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", + status, hearingId, body); + + if (HttpStatus.SC_NOT_FOUND == status) { + // older courtscheduler releases signal no-session as a bare 404 - normalise to the + // 422 NO_SESSION_FOUND contract so callers see a single failure shape + final JsonObject noSessionBody = Json.createObjectBuilder() + .add(ERROR_CODE, NO_SESSION_FOUND) + .add(MESSAGE, body.getString(MESSAGE, + "No court-schedule session found for hearingId " + hearingId + " on " + startDate)) + .build(); + throw new MoveHearingToPastDateException(HttpStatus.SC_UNPROCESSABLE_ENTITY, noSessionBody, + "moveHearingToPastDate found no session for hearingId " + hearingId); + } + + throw new MoveHearingToPastDateException(status, body, + "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); + } + + private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { + return new MoveHearingToPastDateResult( + body.containsKey(COURT_SCHEDULE_ID) ? UUID.fromString(body.getString(COURT_SCHEDULE_ID)) : null, + body.getString(COURT_ROOM_ID, null), + body.containsKey(SESSION_DATE) ? LocalDate.parse(body.getString(SESSION_DATE)) : null, + body.getString(SESSION_START_TIME, null), + body.getString(SESSION_END_TIME, null), + body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); + } } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index fc4682c2d..0306ecd09 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.net.URISyntaxException; +import java.net.URL; +import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -27,8 +29,8 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.StringEntity; @@ -37,35 +39,34 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.json.JsonObject; + @SuppressWarnings({"squid:S1312", "squid:S2629", "squid:S6813"}) @ApplicationScoped public class HearingSlotsService { private static final Logger LOGGER = LoggerFactory.getLogger(HearingSlotsService.class); public static final String HEARING_DATE = "hearingDate"; + private static final String HEARING_ID = "hearingId"; private static final String HEARING_RESOURCE = "/hearingslots"; + private static final String SESSIONS_RESOURCE = "/sessions"; + private static final String HEARINGS_RESOURCE = "/hearings"; private static final String VALIDATE_SESSION_AVAILABILITY_RESOURCE = "/validate-session-availability"; - private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE = "/list/hearingslots"; - private static final String HEARING_SEARCH_BOOK_RESOURCE = "/searchlist/hearingslots"; - private static final String COURTSCHEDULES_RESOURCE = "/courtschedule/search.court-schedules-by-id"; - private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS = "application/vnd.courtscheduler.list.hearings-in-court-sessions+json"; + private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE = HEARINGS_RESOURCE; + private static final String COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS = "application/vnd.courtscheduler.list.hearings-in-sessions+json"; private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json"; private static final String COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID = "application/vnd.courtscheduler.search.court-schedules-by-id+json"; - private static final String COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.remove.hearing.slots+json"; + private static final String COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.release.sessions+json"; private static final String COUTRT_SCHEDULER_HEARING_IDS = "application/vnd.courtscheduler.get.hearing.ids+json"; - private static final String COURTSCHEDULER_SEARCH_BOOK_COURTSCHEDULES = "application/vnd.courtscheduler.search.book.hearing.slots+json"; + private static final String COURTSCHEDULER_MAGS_SEARCH_BOOK = "application/vnd.courtscheduler.mags.search.and.book+json"; + private static final String COURTSCHEDULER_CROWN_SEARCH_BOOK = "application/vnd.courtscheduler.crown.search.and.book+json"; private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json"; - private static final String MULTIDAY_SEARCH_BOOK_RESOURCE = "/multidaysearchandbook/hearingslots"; - private static final String COURTSCHEDULER_MULTIDAY_SEARCH_BOOK = "application/vnd.courtscheduler.multiday.searchandbook.hearing.slots+json"; - - private static final String CROWN_FALLBACK_SEARCH_BOOK_RESOURCE = "/crownfallbacksearchandbook/hearingslots"; - private static final String COURTSCHEDULER_CROWN_FALLBACK_SEARCH_BOOK = "application/vnd.courtscheduler.crown.fallback.search.book.hearing.slots+json"; - - private static final String EXTEND_MULTIDAY_RESOURCE = "/extendmultidayhearing/hearingslots"; private static final String COURTSCHEDULER_EXTEND_MULTIDAY = "application/vnd.courtscheduler.extend.multiday.hearing+json"; + private static final String COURTSCHEDULER_MOVE_TO_PAST_DATE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; + private static final String CJS_CPP_UID = "CJSCPPUID"; @Inject @Value(key = "courtscheduler.base.url", defaultValue = "http://localhost:8080/listingcourtscheduler-api/rest/courtscheduler") @@ -86,11 +87,15 @@ public Response validateSessionAvailability(final JsonObject payload) { } public Response extendMultiDayHearing(final JsonObject payload) { - return post(EXTEND_MULTIDAY_RESOURCE, COURTSCHEDULER_EXTEND_MULTIDAY, payload, false); + if (payload == null || payload.isEmpty()) { + throw new DataValidationException("Payload for %s is null or empty ....".formatted(COURTSCHEDULER_EXTEND_MULTIDAY)); + } + final String hearingId = payload.getString(HEARING_ID); + return patch(HEARINGS_RESOURCE + "/" + hearingId, COURTSCHEDULER_EXTEND_MULTIDAY, payload); } public Response searchBookSlots(final Map params) { - return query(HEARING_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_SEARCH_BOOK_COURTSCHEDULES, params); + return postSearchBook(COURTSCHEDULER_MAGS_SEARCH_BOOK, params); } public Response listHearingInCourtSessions(final Object payload) { @@ -99,14 +104,14 @@ public Response listHearingInCourtSessions(final Object payload) { } try { - final HttpPut httpPut = new HttpPut(new URIBuilder(baseUri + COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE).build()); - httpPut.addHeader(CONTENT_TYPE, COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS); - httpPut.addHeader(CJS_CPP_UID, getUserId().toString()); + final HttpPost httpPost = new HttpPost(new URIBuilder(baseUri + COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS_RESOURCE).build()); + httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_LIST_HEARING_IN_COURT_SESSIONS); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); final StringEntity requestEntity = new StringEntity(this.objectMapper.writeValueAsString(payload)); - httpPut.setEntity(requestEntity); + httpPost.setEntity(requestEntity); - final HttpResponse httpResponse = execute(httpPut); + final HttpResponse httpResponse = execute(httpPost); if (isOk(httpResponse)) { if (LOGGER.isInfoEnabled()) { @@ -135,15 +140,49 @@ public Response listHearingInCourtSessions(final Object payload) { } public Response getCourtSchedulesById(final Map params) { - return query(COURTSCHEDULES_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params); + return query(SESSIONS_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params); } public Response multiDaySearchAndBook(final Map params) { - return query(MULTIDAY_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_MULTIDAY_SEARCH_BOOK, params); + return postSearchBook(COURTSCHEDULER_CROWN_SEARCH_BOOK, params); } public Response crownFallbackSearchAndBook(final Map params) { - return query(CROWN_FALLBACK_SEARCH_BOOK_RESOURCE, COURTSCHEDULER_CROWN_FALLBACK_SEARCH_BOOK, params); + return postSearchBook(COURTSCHEDULER_CROWN_SEARCH_BOOK, params); + } + + public Response moveHearingToPastDate(final UUID hearingId, final JsonObject payload) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date for hearing id '{}'", hearingId); + } + + try { + final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + "/" + hearingId).toString()); + httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_MOVE_TO_PAST_DATE); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); + + final StringEntity requestEntity = new StringEntity(payload.toString()); + httpPost.setEntity(requestEntity); + + final HttpResponse httpResponse = execute(httpPost); + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + final String entityBodyAsString = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date returned status {}", statusCode); + } + + return Response + .status(statusCode) + .entity(entityBodyAsString.isBlank() ? null : stringToJsonObjectConverter.convert(entityBodyAsString)) + .build(); + } catch (IOException ex) { + LOGGER.error("Exception thrown on trying to move hearing to past date", ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } } public void delete(final UUID hearingId) { @@ -152,7 +191,7 @@ public void delete(final UUID hearingId) { } try { - final HttpDelete httpDelete = new HttpDelete(new URIBuilder(baseUri + HEARING_RESOURCE + "/" + hearingId).build()); + final HttpDelete httpDelete = new HttpDelete(new URIBuilder(baseUri + SESSIONS_RESOURCE + "/" + hearingId).build()); httpDelete.addHeader(CONTENT_TYPE, COURTSCHEDULER_DELETE_HEARING_SLOTS_TYPE); httpDelete.addHeader(CJS_CPP_UID, getUserId().toString()); @@ -253,31 +292,132 @@ private Response post(final String urlPath, final String contentTypeHeader, fina } httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); httpPost.setEntity(new StringEntity(payload.toString())); + return executeAndBuildResponse(httpPost, contentTypeHeader, "POST"); + } catch (URISyntaxException | IOException ex) { + LOGGER.error("Exception thrown on trying to Retrieving %s".formatted(contentTypeHeader), ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } + } - final HttpResponse httpResponse = execute(httpPost); - final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); - final Object entity = responseBody == null || responseBody.isBlank() - ? Json.createObjectBuilder().build() - : stringToJsonObjectConverter.convert(responseBody); - - final int statusCode = httpResponse.getStatusLine().getStatusCode(); - if (isOk(httpResponse)) { - if (LOGGER.isInfoEnabled()) { - LOGGER.info("Retrieve {} successfully", contentTypeHeader); - } - } else { - LOGGER.error("Retrieve {} failed with status code:{}", contentTypeHeader, statusCode); - } + private Response patch(final String urlPath, final String contentTypeHeader, final JsonObject payload) { + if (LOGGER.isInfoEnabled() && Objects.nonNull(payload)) { + LOGGER.info("{} PATCH in CourtScheduler S & L with payload '{}'", contentTypeHeader, payload); + } + if (payload == null || payload.isEmpty()) { + throw new DataValidationException("Payload for PATCH %s is null or empty ....".formatted(contentTypeHeader)); + } + try { + final HttpPatch httpPatch = new HttpPatch(new URIBuilder(baseUri + urlPath).build()); + httpPatch.addHeader(CONTENT_TYPE, contentTypeHeader); + httpPatch.addHeader(CJS_CPP_UID, getUserId().toString()); + httpPatch.setEntity(new StringEntity(payload.toString())); + return executeAndBuildResponse(httpPatch, contentTypeHeader, "PATCH"); + } catch (URISyntaxException | IOException ex) { + LOGGER.error("Exception thrown on trying to PATCH %s".formatted(contentTypeHeader), ex); return Response - .status(statusCode) - .entity(entity) + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) .build(); + } + } + + /** + * Posts a search-and-book request to /hearings/{hearingId} with a typed JSON body. + * Extracts "hearingId" from params map for the path; builds the remaining params as a JSON body, + * converting numeric fields (durationInMinutes) to numbers and boolean fields (isPolice) to booleans. + * + *

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

+ */ + private Response postSearchBook(final String contentTypeHeader, final Map params) { + if (params == null) { + throw new DataValidationException("Params for %s is null ....".formatted(contentTypeHeader)); + } + final String hearingId = params.get(HEARING_ID); + if (hearingId == null || hearingId.isBlank()) { + throw new DataValidationException("hearingId missing from params for %s".formatted(contentTypeHeader)); + } + + final Map bodyParams = new HashMap<>(params); + bodyParams.remove(HEARING_ID); + final JsonObject payload = buildTypedJsonBody(bodyParams); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("{} POST /hearings/{} in CourtScheduler S & L with payload '{}'", contentTypeHeader, hearingId, payload); + } + + try { + final HttpPost httpPost = new HttpPost(new URIBuilder(baseUri + HEARINGS_RESOURCE + "/" + hearingId).build()); + httpPost.addHeader(CONTENT_TYPE, contentTypeHeader); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); + httpPost.setEntity(new StringEntity(payload.toString())); + return executeAndBuildResponse(httpPost, contentTypeHeader, "POST"); } catch (URISyntaxException | IOException ex) { - LOGGER.error("Exception thrown on trying to Retrieving %s".formatted(contentTypeHeader), ex); + LOGGER.error("Exception thrown on trying to POST %s".formatted(contentTypeHeader), ex); return Response .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) .entity(ex.getMessage()) .build(); } } + + /** + * Builds a typed JSON body from a params map. Converts "durationInMinutes" values to JSON numbers + * and "isPolice" values to JSON booleans; all other entries are added as strings. + * Null values are silently omitted. + */ + static JsonObject buildTypedJsonBody(final Map params) { + final javax.json.JsonObjectBuilder bodyBuilder = Json.createObjectBuilder(); + params.forEach((key, value) -> { + if (value == null) { + return; + } + if ("durationInMinutes".equals(key)) { + try { + bodyBuilder.add(key, Integer.parseInt(value)); + } catch (NumberFormatException e) { + bodyBuilder.add(key, value); + } + } else if ("isPolice".equals(key)) { + bodyBuilder.add(key, Boolean.parseBoolean(value)); + } else { + bodyBuilder.add(key, value); + } + }); + return bodyBuilder.build(); + } + + /** + * Executes an already-configured HTTP request and builds a JAX-RS Response from the outcome. + * Logs success or failure at INFO/ERROR respectively. On IOException or URISyntaxException + * the caller's catch block handles the 500 β€” this method only handles the execute + response + * building path (no try/catch here). + */ + private Response executeAndBuildResponse(final HttpRequestBase request, final String contentTypeHeader, final String method) + throws IOException { + final HttpResponse httpResponse = execute(request); + final String responseBody = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + final Object entity = responseBody == null || responseBody.isBlank() + ? Json.createObjectBuilder().build() + : stringToJsonObjectConverter.convert(responseBody); + + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + if (isOk(httpResponse)) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("{} {} successfully", method, contentTypeHeader); + } + } else { + LOGGER.error("{} {} failed with status code:{}", method, contentTypeHeader, statusCode); + } + return Response + .status(statusCode) + .entity(entity) + .build(); + } } diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java new file mode 100644 index 000000000..94f6b787e --- /dev/null +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java @@ -0,0 +1,162 @@ +package uk.gov.moj.cpp.listing.common.service; + +import static javax.json.Json.createObjectBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.json.JsonObject; +import javax.ws.rs.core.Response; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CourtSchedulerServiceAdapterMoveHearingToPastDateTest { + + @InjectMocks + private CourtSchedulerServiceAdapter adapter; + + @Mock + private HearingSlotsService hearingSlotsService; + + @Mock + private Response response; + + @Test + void shouldParseSlotDetailsOn200() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + final JsonObject body = createObjectBuilder() + .add("hearingId", hearingId.toString()) + .add("courtScheduleId", courtScheduleId.toString()) + .add("courtRoomId", "9d324f4f-6c3b-451f-ac1e-f459db781153") + .add("sessionDate", "2026-05-01") + .add("sessionStartTime", "2026-05-01T09:00:00Z") + .add("sessionEndTime", "2026-05-01T17:00:00Z") + .add("durationInMinutes", 30) + .add("source", "MOVE_TO_PAST_DATE") + .build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30); + + assertThat(result.courtScheduleId(), is(courtScheduleId)); + assertThat(result.courtRoomId(), is("9d324f4f-6c3b-451f-ac1e-f459db781153")); + assertThat(result.sessionDate(), is(startDate)); + assertThat(result.sessionStartTime(), is("2026-05-01T09:00:00Z")); + assertThat(result.sessionEndTime(), is("2026-05-01T17:00:00Z")); + assertThat(result.durationInMinutes(), is(30)); + } + + @Test + void shouldOmitDurationInRequestWhenNotSupplied() { + final UUID hearingId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, UUID.randomUUID(), LocalDate.parse("2026-05-01"), null); + + assertThat(result.durationInMinutes(), is(nullValue())); + } + + @Test + void shouldThrowWith422AndErrorCodeWhenFutureDate() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "must not be after today") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2999-01-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldThrowWith422NoSessionFoundWhenCourtschedulerReturns422() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "NO_SESSION_FOUND") + .add("message", "No session available") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNormaliseLegacy404ToA422NoSessionFound() { + final JsonObject body = createObjectBuilder().build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(HttpStatus.SC_UNPROCESSABLE_ENTITY)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNotSendHearingIdInRequestBody() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + adapter.moveHearingToPastDate(hearingId, courtCentreId, LocalDate.parse("2026-05-01"), 30); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(JsonObject.class); + verify(hearingSlotsService).moveHearingToPastDate(eq(hearingId), requestCaptor.capture()); + final JsonObject request = requestCaptor.getValue(); + assertThat(request.containsKey("hearingId"), is(false)); + assertThat(request.getString("courtCentreId"), is(courtCentreId.toString())); + assertThat(request.getString("jurisdiction"), is("MAGISTRATES")); + assertThat(request.getString("startDate"), is("2026-05-01")); + assertThat(request.getInt("durationInMinutes"), is(30)); + } +} diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java index a7604f629..5e295d46f 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java @@ -24,8 +24,8 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; @@ -64,10 +64,10 @@ class HearingSlotsServiceTest { @Captor private ArgumentCaptor httpGetCaptor; @Captor - private ArgumentCaptor httpPutCaptor; - @Captor private ArgumentCaptor httpDeleteCaptor; @Captor + private ArgumentCaptor httpPatchCaptor; + @Captor private ArgumentCaptor httpPostCaptor; @InjectMocks @@ -115,7 +115,7 @@ void shouldDeleteSuccessfully() throws Exception { when(httpClientBuilder.build()).thenReturn(httpClient); when(httpClient.execute(any(HttpDelete.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); - when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(statusLine.getStatusCode()).thenReturn(Response.Status.ACCEPTED.getStatusCode()); // When hearingSlotsService.delete(TEST_HEARING_ID); @@ -123,7 +123,7 @@ void shouldDeleteSuccessfully() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -236,7 +236,7 @@ void shouldGetCourtSchedulesByIdSuccessfully() throws Exception { assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); verify(httpClient).execute(httpGetCaptor.capture()); HttpGet capturedGet = httpGetCaptor.getValue(); - assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/courtschedule/search.court-schedules-by-id?key=value")); + assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/sessions?key=value")); } } @@ -294,7 +294,7 @@ void shouldHandleDeleteError() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -314,7 +314,7 @@ void shouldHandleDeleteIOException() throws Exception { // Then verify(httpClient).execute(httpDeleteCaptor.capture()); HttpDelete capturedDelete = httpDeleteCaptor.getValue(); - assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/hearingslots/" + TEST_HEARING_ID)); + assertThat(capturedDelete.getURI().toString(), is(BASE_URI + "/sessions/" + TEST_HEARING_ID)); } } @@ -392,28 +392,30 @@ void shouldHandleSystemUserProviderError() { @Test void shouldSearchAndBookSlotsSuccessfully() throws Exception { - // Given + // Given β€” hearingId is mandatory; other params become the JSON body Map params = new HashMap<>(); - params.put("key", "value"); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("ouCode", "OU123"); when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpGet.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); - when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); - when(stringToJsonObjectConverter.convert(any())).thenReturn(mock(javax.json.JsonObject.class)); + when(httpResponse.getEntity()).thenReturn(null); // When Response response = hearingSlotsService.searchBookSlots(params); // Then assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpGetCaptor.capture()); - HttpGet capturedGet = httpGetCaptor.getValue(); - assertThat(capturedGet.getURI().toString(), is(BASE_URI + "/searchlist/hearingslots?key=value")); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.mags.search.and.book+json")); } } @@ -426,7 +428,7 @@ void shouldThrowExceptionWhenSearchAndBookParamsAreNull() { try { hearingSlotsService.searchBookSlots(params); } catch (DataValidationException e) { - assertThat(e.getMessage(), is("Params for search application/vnd.courtscheduler.search.book.hearing.slots+json is null ....")); + assertThat(e.getMessage(), is("Params for application/vnd.courtscheduler.mags.search.and.book+json is null ....")); } } @@ -612,7 +614,7 @@ void shouldListHearingInCourtSessionsSuccessfully() throws Exception { MockedStatic entityUtilsMockedStatic = Mockito.mockStatic(EntityUtils.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); org.apache.http.HttpEntity entity = mock(org.apache.http.HttpEntity.class); @@ -625,9 +627,11 @@ void shouldListHearingInCourtSessionsSuccessfully() throws Exception { // Then assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpPutCaptor.capture()); - HttpPut capturedPut = httpPutCaptor.getValue(); - assertThat(capturedPut.getURI().toString(), is(BASE_URI + "/list/hearingslots")); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings")); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.list.hearings-in-sessions+json")); } } @@ -642,7 +646,7 @@ void shouldHandleListHearingInCourtSessionsErrorResponse() throws Exception { MockedStatic entityUtilsMockedStatic = Mockito.mockStatic(EntityUtils.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode()); org.apache.http.HttpEntity entity = mock(org.apache.http.HttpEntity.class); @@ -667,7 +671,7 @@ void shouldHandleListHearingInCourtSessionsIOException() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPut.class))).thenThrow(new IOException("Connection refused")); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused")); // When Response response = hearingSlotsService.listHearingInCourtSessions(payload); @@ -692,7 +696,7 @@ void shouldExtendMultiDayHearingSuccessfully() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); @@ -700,12 +704,12 @@ void shouldExtendMultiDayHearingSuccessfully() throws Exception { Response response = hearingSlotsService.extendMultiDayHearing(payload); assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); - verify(httpClient).execute(httpPostCaptor.capture()); - HttpPost capturedPost = httpPostCaptor.getValue(); - assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/extendmultidayhearing/hearingslots")); - assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + verify(httpClient).execute(httpPatchCaptor.capture()); + HttpPatch capturedPatch = httpPatchCaptor.getValue(); + assertThat(capturedPatch.getURI().toString(), is(BASE_URI + "/hearings/11111111-1111-1111-1111-111111111111")); + assertThat(capturedPatch.getFirstHeader("Content-Type").getValue(), is("application/vnd.courtscheduler.extend.multiday.hearing+json")); - assertThat(capturedPost.getFirstHeader("Accept"), is(nullValue())); + assertThat(capturedPatch.getFirstHeader("Accept"), is(nullValue())); } } @@ -733,7 +737,7 @@ void shouldDoesNotSwallow422FromExtendMultiDayHearing() throws Exception { try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); when(httpClientBuilder.build()).thenReturn(httpClient); - when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); when(httpResponse.getStatusLine()).thenReturn(statusLine); when(statusLine.getStatusCode()).thenReturn(422); when(httpResponse.getEntity()).thenReturn(mock(org.apache.http.HttpEntity.class)); @@ -743,4 +747,315 @@ void shouldDoesNotSwallow422FromExtendMultiDayHearing() throws Exception { assertThat(response.getStatus(), is(422)); } } + + // ─── patch() coverage tests ────────────────────────────────────────── + + @Test + void patchShouldReturnOkOnSuccess() throws Exception { + javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", "22222222-2222-2222-2222-222222222222") + .add("durationInMinutes", 60) + .build(); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPatchCaptor.capture()); + assertThat(httpPatchCaptor.getValue().getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.extend.multiday.hearing+json")); + } + } + + @Test + void patchShouldReturn400OnNonOkStatus() throws Exception { + javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", "22222222-2222-2222-2222-222222222222") + .add("durationInMinutes", 60) + .build(); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + } + + @Test + void patchShouldReturn500OnIOException() throws Exception { + javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", "22222222-2222-2222-2222-222222222222") + .add("durationInMinutes", 60) + .build(); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPatch.class))).thenThrow(new IOException("Patch connection refused")); + + Response response = hearingSlotsService.extendMultiDayHearing(payload); + + assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())); + } + } + + @Test + void patchShouldThrowWhenPayloadIsNull() { + try { + hearingSlotsService.extendMultiDayHearing(null); + } catch (uk.gov.moj.cpp.listing.domain.exception.DataValidationException e) { + assertThat(e.getMessage(), is("Payload for application/vnd.courtscheduler.extend.multiday.hearing+json is null or empty ....")); + } + } + + // ─── postSearchBook typed-body tests ──────────────────────────────── + + @Test + void postSearchBookShouldSendDurationInMinutesAsJsonNumber() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("durationInMinutes", "120"); + params.put("ouCode", "B01LY00"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class); + MockedStatic entityUtilsMock = Mockito.mockStatic(org.apache.http.util.EntityUtils.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.searchBookSlots(params); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + // hearingId must appear in the path + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + // Content-type must be mags search-and-book + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.mags.search.and.book+json")); + } + } + + @Test + void postSearchBookShouldSendIsPolicAsBooleanTrue() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("isPolice", "true"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + hearingSlotsService.searchBookSlots(params); + + verify(httpClient).execute(httpPostCaptor.capture()); + // Verify the body contains boolean true (not the string "true") + String body = org.apache.http.util.EntityUtils.toString(httpPostCaptor.getValue().getEntity()); + assertThat(body.contains("\"isPolice\":true"), is(true)); + } + } + + @Test + void postSearchBookCrownShouldUseHearingIdInPath() throws Exception { + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("durationInMinutes", "720"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + Response response = hearingSlotsService.multiDaySearchAndBook(params); + + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.crown.search.and.book+json")); + } + } + + @Test + void postSearchBookShouldNotSendHearingIdInBody() throws Exception { + // Regression guard: the courtscheduler crown/mags search-and-book schemas are + // additionalProperties:false and no longer carry hearingId β€” it travels in the + // /hearings/{hearingId} path only. Sending it in the body triggers a 400 schema + // rejection that broke CrownScheduledListingIT (SPRDT-1011 vs SPRDT-1089 contract skew). + Map params = new HashMap<>(); + params.put("hearingId", TEST_HEARING_ID.toString()); + params.put("courtCentreId", "b21a7d44-3e0c-4f6a-8b2d-1c9e5f7a3d20"); + params.put("hearingDate", "2026-07-06"); + params.put("durationInMinutes", "720"); + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + hearingSlotsService.multiDaySearchAndBook(params); + + verify(httpClient).execute(httpPostCaptor.capture()); + HttpPost capturedPost = httpPostCaptor.getValue(); + // hearingId identifies the hearing via the path... + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + // ...and must NOT appear in the request body. + final String body = org.apache.http.util.EntityUtils.toString(capturedPost.getEntity()); + assertThat("body must not carry hearingId", body.contains("hearingId"), is(false)); + assertThat("body still carries the booking fields", body.contains("courtCentreId"), is(true)); + } + } + + @Test + void buildTypedJsonBodyShouldConvertDurationToNumber() { + Map params = new HashMap<>(); + params.put("durationInMinutes", "90"); + params.put("ouCode", "B01LY00"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getInt("durationInMinutes"), is(90)); + assertThat(result.getString("ouCode"), is("B01LY00")); + } + + @Test + void buildTypedJsonBodyShouldConvertIsPoliceToBooleanFalse() { + Map params = new HashMap<>(); + params.put("isPolice", "false"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getBoolean("isPolice"), is(false)); + } + + @Test + void buildTypedJsonBodyShouldSkipNullValues() { + Map params = new HashMap<>(); + params.put("ouCode", null); + params.put("durationInMinutes", "30"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.containsKey("ouCode"), is(false)); + assertThat(result.getInt("durationInMinutes"), is(30)); + } + + @Test + void buildTypedJsonBodyShouldFallbackToStringWhenDurationIsNotANumber() { + Map params = new HashMap<>(); + params.put("durationInMinutes", "notANumber"); + + javax.json.JsonObject result = HearingSlotsService.buildTypedJsonBody(params); + + assertThat(result.getString("durationInMinutes"), is("notANumber")); + } + + @Test + public void shouldPostMoveHearingToPastDateSuccessfully() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + final HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.move-hearing-to-past-date+json")); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateErrorResponse() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(422); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(422)); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateIOException() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Test exception")); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())); + } + } } \ No newline at end of file diff --git a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java index 52a0dfb34..7c1b2b3c0 100644 --- a/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java +++ b/listing-domain/listing-domain-aggregate/src/test/java/uk/gov/moj/cpp/listing/domain/aggregate/HearingAggregateTest.java @@ -154,14 +154,14 @@ class HearingAggregateTest { private static final Logger LOGGER = Logger.getLogger(HearingAggregateTest.class.getName()); @Test - public void shouldRaiseCrownHearingMigratedToCourtScheduleEvent() { + void shouldRaiseCrownHearingMigratedToCourtScheduleEvent() { final UUID courtScheduleId = randomUUID(); final LocalDate hearingDate = now(); final List schedules = singletonList(new HearingDayCourtSchedule(courtScheduleId, hearingDate)); final List events = hearing.raiseCrownHearingMigratedToCourtSchedule(hearingId, schedules) - .collect(Collectors.toList()); + .toList(); assertThat(events, hasSize(1)); assertThat(events.get(0), CoreMatchers.instanceOf(CrownHearingMigratedToCourtschedule.class)); @@ -1647,12 +1647,12 @@ void shouldBeAbleToEjectApplicationAndAvailableSlotsForHearingFreedForCrown() { void shouldBeAbleToEjectCaseAndAvailableSlotsForHearingFreedForCrown() { final UUID caseId = randomUUID(); - final UUID hearingId = randomUUID(); + final UUID localHearingId = randomUUID(); final String removalReason = "removal reason"; hearing.apply(HearingListed.hearingListed() .withHearing(uk.gov.justice.listing.events.Hearing.hearing() - .withId(hearingId) + .withId(localHearingId) .withType(uk.gov.justice.listing.events.Type.type().build()) .withHearingLanguage(HearingLanguage.ENGLISH) .withJurisdictionType(CROWN) @@ -1674,15 +1674,15 @@ void shouldBeAbleToEjectCaseAndAvailableSlotsForHearingFreedForCrown() { .build() ); - var listedHearing = hearing.ejectCase(hearingId, caseId, removalReason).toList(); + var listedHearing = hearing.ejectCase(localHearingId, caseId, removalReason).toList(); assertThat(listedHearing, hasSize(2)); var availableSlotsForHearingFreed = (AvailableSlotsForHearingFreed) listedHearing.get(0); var caseEjected = (CaseEjected) listedHearing.get(1); - assertThat(availableSlotsForHearingFreed.getHearingId(), is(hearingId)); - assertThat(caseEjected.getHearingId(), is(hearingId)); + assertThat(availableSlotsForHearingFreed.getHearingId(), is(localHearingId)); + assertThat(caseEjected.getHearingId(), is(localHearingId)); } @Test @@ -8116,6 +8116,97 @@ void shouldNotAllocateCrownHearingWhenHearingDaysEmpty() { assertThat(allocationEvents.size(), is(0)); } + @Test + void shouldEmitHearingAllocatedAndRescheduledWithAllocatedTrueWhenCrownHearingMovesFromDraftToNonDraftSession() { + // Regression test for the CROWN reschedule allocation bug: + // A hearing originally in a draft session (unallocated) is rescheduled onto a non-draft session. + // After enrichment fixes the hearingDay to isDraft=false, the aggregate must: + // 1. emit hearing-allocated-for-listing-v2 (allocation gate opens) + // 2. emit HearingRescheduled with allocated=true (not the previous allocated=false) + final UUID crownHearingId = randomUUID(); + final UUID crownCourtRoomId = randomUUID(); + final UUID oldDraftCourtScheduleId = randomUUID(); + final UUID newNonDraftCourtScheduleId = randomUUID(); + final LocalDate originalDate = LocalDate.now().plusDays(5); + final LocalDate rescheduledDate = LocalDate.now().plusDays(10); + + // Step 1: set up the hearing in its original DRAFT / unallocated state + hearing.apply(HearingListed.hearingListed() + .withHearing(uk.gov.justice.listing.events.Hearing.hearing() + .withId(crownHearingId) + .withType(uk.gov.justice.listing.events.Type.type().build()) + .withHearingLanguage(HearingLanguage.ENGLISH) + .withJurisdictionType(uk.gov.justice.core.courts.JurisdictionType.CROWN) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(oldDraftCourtScheduleId) + .withHearingDate(originalDate) + .withDurationMinutes(240) + .withIsDraft(true) // old session is draft β†’ gate closed + .build())) + .withCourtRoomId(crownCourtRoomId) + .withStartDate(originalDate) + .withEndDate(originalDate) + .withEstimatedMinutes(240) + .withEstimatedDuration("240 minutes") + .withAllocated(false) + .withListedCases(Arrays.asList(uk.gov.justice.listing.events.ListedCase.listedCase() + .withId(randomUUID()) + .withDefendants(Arrays.asList(Defendant.defendant() + .withId(randomUUID()) + .withOffences(Arrays.asList(Offence.offence() + .withId(randomUUID()) + .build())) + .build())) + .build())) + .build()) + .build()); + + // Confirm gate is closed in draft state (guard: existing behaviour preserved) + final List preMigrationEvents = Stream.of( + hearing.applyAllocationRules(of(randomUUID()), true, true, emptyList(), empty(), null)) + .flatMap(i -> i).toList(); + assertThat("isDraft=true must prevent allocation", preMigrationEvents.size(), is(0)); + + // Step 2: simulate the enrichment layer having promoted the new non-draft id + isDraft=false + hearing.apply(HearingDaysChangedForHearing.hearingDaysChangedForHearing() + .withHearingId(crownHearingId) + .withHearingDays(Arrays.asList( + HearingDay.hearingDay() + .withCourtScheduleId(newNonDraftCourtScheduleId) + .withHearingDate(rescheduledDate) + .withDurationMinutes(240) + .withIsDraft(false) // new session is non-draft β†’ gate opens + .build())) + .build()); + + // Step 3: applyAllocationRules must now emit hearing-allocated-for-listing-v2 + final List allocationEvents = Stream.of( + hearing.applyAllocationRules(of(randomUUID()), true, true, emptyList(), empty(), null)) + .flatMap(i -> i).toList(); + assertThat("non-draft session must trigger allocation", allocationEvents.size(), is(1)); + assertTrue(allocationEvents.get(0) instanceof HearingAllocatedForListingV2, + "expected HearingAllocatedForListingV2, got: " + allocationEvents.get(0).getClass().getSimpleName()); + + // Step 4: apply the allocation event so aggregate knows it is now allocated + hearing.apply((HearingAllocatedForListingV2) allocationEvents.get(0)); + + // Step 5: applyRescheduledCheck with a StartDateChangedForHearing must emit HearingRescheduled + // with allocated=true (not the buggy allocated=false) + final StartDateChangedForHearing startDateChanged = StartDateChangedForHearing.startDateChangedForHearing() + .withHearingId(crownHearingId) + .withStartDate(rescheduledDate.toString()) + .build(); + final List rescheduledEvents = hearing.applyRescheduledCheck(Arrays.asList(startDateChanged)).toList(); + assertThat("applyRescheduledCheck must emit HearingRescheduled", rescheduledEvents.size(), is(1)); + assertTrue(rescheduledEvents.get(0) instanceof uk.gov.justice.listing.events.HearingRescheduled, + "expected HearingRescheduled"); + final uk.gov.justice.listing.events.HearingRescheduled rescheduled = + (uk.gov.justice.listing.events.HearingRescheduled) rescheduledEvents.get(0); + assertThat("HearingRescheduled.allocated must be true after moving to non-draft session", + rescheduled.getAllocated(), is(true)); + } + // ─── CROWN vacate-trial slot payback tests ─────────────────────────── @Test @@ -8360,7 +8451,7 @@ void shouldPassConfiguredHearingTypeDurationThroughForListForSplit() { } private void assertListForSplitEstimatedMinutes(final Integer hearingTypeDuration, final int expected) { - final List listedCases = singletonList(uk.gov.justice.listing.events.ListedCase + final List splitListedCases = singletonList(uk.gov.justice.listing.events.ListedCase .listedCase() .withId(randomUUID()) .withDefendants(singletonList(Defendant.defendant() @@ -8371,7 +8462,7 @@ private void assertListForSplitEstimatedMinutes(final Integer hearingTypeDuratio .build())) .build()); - final Stream listedHearing = hearing.listForSplit(type, listedCases, courtCentreId, + final Stream listedHearing = hearing.listForSplit(type, splitListedCases, courtCentreId, "court name", courtRoomId, jurisdictionType, ZonedDateTime.now(), null, null, emptyList(), emptyList(), hearingTypeDuration); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java index eff4b3d57..32c13e5dd 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingMultidayIT.java @@ -37,10 +37,11 @@ * *

A raw multi-day Crown update with NO courtScheduleId submitted (hearingDays empty, single * nonDefaultDay with duration > MINUTES_IN_DAY and no courtScheduleId) must hit courtscheduler's - * {@code /extendmultidayhearing/hearingslots} POST endpoint with the full requested duration β€” - * the listing officer has not yet picked sessions, so courtscheduler is asked to extend/book them. - * When a courtScheduleId IS submitted the update reverts to {@code enrichCrownCourtScheduleFirst} - * (multiDaySearchAndBook) β€” the pre-d62d3446 behaviour β€” and extend-multiday is NOT called; that + * {@code PATCH /hearings/{hearingId}} (extend.multiday.hearing) endpoint with the full requested + * duration β€” the listing officer has not yet picked sessions, so courtscheduler is asked to + * extend/book them. When a courtScheduleId IS submitted the update reverts to + * {@code enrichCrownCourtScheduleFirst} (multiDaySearchAndBook via POST /hearings/{id}, + * crown.search.and.book) β€” the pre-d62d3446 behaviour β€” and extend-multiday is NOT called; that * courtScheduleId-wins routing is locked by HearingEnrichmentOrchestratorTest. The single-day CROWN * path (duration ≀ MINUTES_IN_DAY) and fresh allocations via list-court-hearing are likewise unchanged. * @@ -95,7 +96,7 @@ void shouldCallExtendMultiDayHearingOnListingCourtScheduler_whenCrownMultiDayUpd getLoggedInHeader()); // Core assertion: the CROWN update was routed through handleCrownMultiDayExtension and hit - // POST /extendmultidayhearing/hearingslots with hearingId + full 1080-minute duration. + // PATCH /hearings/{hearingId} (extend.multiday.hearing) with hearingId + full 1080-minute duration. verifyExtendMultiDayHearingCalled(hearingId.toString(), MULTI_DAY_TOTAL_DURATION_MINUTES); // Drain our own async aftermath before the test ends: viewstore projection (event listener) // AND the public hearing-changes-saved (event processor) β€” otherwise the next test's cleanup diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java new file mode 100644 index 000000000..8e0ab73f8 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingScheduleOnlyIT.java @@ -0,0 +1,242 @@ +package uk.gov.moj.cpp.listing.it; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessionsForCourtSchedule; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubSearchCourtSchedulesByIdSession; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentre; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentreById; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtMappings; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataHearingTypes; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataOrganisationUnitById; + +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.it.util.RestPollerHelper; +import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; +import uk.gov.moj.cpp.listing.steps.data.CourtCentreData; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.io.InputStream; +import java.text.MessageFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; + +import com.jayway.jsonpath.matchers.JsonPathMatchers; +import uk.gov.justice.services.common.http.HeaderConstants; +import uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder; +import uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher; +import uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher; + +/** + * Crown Phase 2: update-hearing-for-listing with a SCHEDULE-ONLY payload β€” the nonDefaultDay + * carries a courtScheduleId but NO room fields (no top-level courtRoomId, no selectedCourtCentre, + * no roomId on the nonDefaultDay). + * + *

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

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

Sibling {@link CrownUpdateHearingSingleDayIT} covers the room-bearing (UI-parity) payload; + * this class locks the schedule-only shape sent by API callers. + */ +public class CrownUpdateHearingScheduleOnlyIT extends AbstractIT { + + private static final String MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING = + "application/vnd.listing.command.update-hearing-for-listing+json"; + private static final String UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY = + "listing.command.update-hearing-for-listing"; + private static final String SCHEDULE_ONLY_PAYLOAD = + "test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json"; + + @Test + void shouldAllocateAndDeriveRoomFromSchedule_whenScheduleOnlyPayloadResolvesToFinalSession() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID scheduleCourtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID finalCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // FINAL session: by-id resolution returns isDraft=false AND the session's courtroom β€” + // the room exists ONLY here, never in the payload. + stubSearchCourtSchedulesByIdSession( + finalCourtScheduleId.toString(), + courtHouseId, + scheduleCourtRoomId, + targetDate, + sessionStart, + false); + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + finalCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + final ListCourtHearingSteps seedSteps = givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, scheduleCourtRoomId); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + scheduleOnlyPayload(courtCentreId, targetDate, finalCourtScheduleId), + getLoggedInHeader()); + + // The hearing must end ALLOCATED with the hearing-level courtroom DERIVED from the + // resolved schedule. Day-level assertions prove the schedule id/room landed on the day. + pollHearingView(hearingId) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(targetDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(true)), + JsonPathMatchers.withJsonPath("$.courtRoomId", + is(scheduleCourtRoomId.toString())), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtScheduleId", + is(finalCourtScheduleId.toString())), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtRoomId", + is(scheduleCourtRoomId.toString()))))); + seedSteps.verifyPublicEVentHearingChangesSaved(hearingId); + } + + @Test + void shouldStayUnallocatedAndRoomless_whenScheduleOnlyPayloadResolvesToDraftSession() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + final UUID draftCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // DRAFT session: by-id resolution returns isDraft=true and NO courtroom (draft sessions + // are room-sanitised on every courtscheduler query path β€” ADR-005 corollary). + stubSearchCourtSchedulesByIdSession( + draftCourtScheduleId.toString(), + courtHouseId, + null, + targetDate, + sessionStart, + true); + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + draftCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, UUID.randomUUID()); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + scheduleOnlyPayload(courtCentreId, targetDate, draftCourtScheduleId), + getLoggedInHeader()); + + // The update must project (startDate moves) with the hearing UNALLOCATED and ROOMLESS β€” + // the draft schedule id lands on the day, but no courtroom may appear at any level. + pollHearingView(hearingId) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(targetDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(false)), + JsonPathMatchers.hasNoJsonPath("$.courtRoomId"), + JsonPathMatchers.withJsonPath("$.hearingDays[0].courtScheduleId", + is(draftCourtScheduleId.toString())), + JsonPathMatchers.hasNoJsonPath("$.hearingDays[0].courtRoomId")))); + } + + private String scheduleOnlyPayload(final UUID courtCentreId, + final LocalDate targetDate, + final UUID courtScheduleId) throws Exception { + final Map placeholders = new HashMap<>(); + placeholders.put("%%COURT_CENTRE_ID%%", courtCentreId.toString()); + placeholders.put("%%TARGET_DATE%%", targetDate.toString()); + placeholders.put("%%COURT_SCHEDULE_ID%%", courtScheduleId.toString()); + return loadAndSubstitute(SCHEDULE_ONLY_PAYLOAD, placeholders); + } + + private uk.gov.justice.services.test.utils.core.http.RestPoller pollHearingView(final UUID hearingId) { + final String url = String.format("%s/%s", getBaseUri(), + MessageFormat.format(readConfig().getProperty("listing.search.hearing"), hearingId.toString())); + return RestPollerHelper.pollWithDefaults( + RequestParamsBuilder + .requestParams(url, "application/vnd.listing.search.hearing+json") + .withHeader(HeaderConstants.USER_ID, getLoggedInUser()) + .build()); + } + + private ListCourtHearingSteps givenARealHearingExists(final UUID hearingId) { + final ListCourtHearingSteps seedSteps = + new ListCourtHearingSteps(HearingsData.hearingsData(hearingId)); + seedSteps.whenCaseIsSubmittedForListing(); + seedSteps.verifyHearingIsCreated(hearingId, 2); + return seedSteps; + } + + private static void givenReferenceDataStubsForUpdateHearing(final UUID courtCentreId, final UUID courtRoomId) { + final CourtCentreData courtCentreData = new CourtCentreData( + courtCentreId, + LocalTime.of(10, 30), + "6:30", + courtRoomId, + "Test Court Centre"); + stubGetReferenceDataCourtCentre(courtCentreData); + stubGetReferenceDataCourtCentreById(courtCentreData); + stubGetReferenceDataCourtMappings(courtCentreData); + stubGetReferenceDataHearingTypes(UUID.randomUUID()); + stubGetReferenceDataOrganisationUnitById(courtCentreId); + } + + private static String loadAndSubstitute(final String classpathResource, + final Map placeholders) throws Exception { + try (InputStream in = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalStateException("Test payload not found on classpath: " + classpathResource); + } + String body = new String(in.readAllBytes()); + for (final Map.Entry e : placeholders.entrySet()) { + body = body.replace(e.getKey(), e.getValue()); + } + return body; + } + } + + private static String buildUpdateHearingUrl(final UUID hearingId) { + final String path = MessageFormat.format( + readConfig().getProperty(UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY), + hearingId.toString()); + return String.format("%s/%s", getBaseUri(), path); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java new file mode 100644 index 000000000..0c9c43a10 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/CrownUpdateHearingSingleDayIT.java @@ -0,0 +1,192 @@ +package uk.gov.moj.cpp.listing.it; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessionsForCourtSchedule; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubSearchCourtSchedulesByIdSession; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentre; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtCentreById; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataCourtMappings; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataHearingTypes; +import static uk.gov.moj.cpp.listing.utils.ReferenceDataStub.stubGetReferenceDataOrganisationUnitById; + +import uk.gov.moj.cpp.listing.steps.data.CourtCentreData; +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.it.util.RestPollerHelper; + +import java.io.InputStream; +import java.text.MessageFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; + +import com.jayway.jsonpath.matchers.JsonPathMatchers; +import uk.gov.justice.services.common.http.HeaderConstants; +import uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder; +import uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher; +import uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher; + +/** + * Regression lock for SPRDT-1011 / J05: a CROWN single-day hearing that was UNALLOCATED is + * updated via update-hearing-for-listing with a non-draft courtScheduleId on nonDefaultDays + * and must end ALLOCATED. + * + *

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

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

Integration test perspective: the JSON schema for update-hearing-for-listing does not expose + * {@code hearingDays} as a submittable field (additionalProperties:false), so the exact stale-id + * scenario from the unit test cannot be reproduced at IT level. This test instead locks the + * end-to-end allocation outcome for the single-day CROWN path: nonDefaultDays carries the + * non-draft courtScheduleId; courtscheduler is stubbed to confirm non-draft; the hearing must + * end ALLOCATED. Reverting the fix causes the enrichment path to break for hearingDays-populated + * flows and the companion unit tests to fail; this IT locks the integration layer. + */ +public class CrownUpdateHearingSingleDayIT extends AbstractIT { + + private static final String MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING = + "application/vnd.listing.command.update-hearing-for-listing+json"; + private static final String UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY = + "listing.command.update-hearing-for-listing"; + + @Test + void shouldAllocateHearing_whenCrownSingleDayUpdatedWithNonDraftCourtScheduleId() throws Exception { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtRoomId = UUID.randomUUID(); + final UUID courtHouseId = UUID.randomUUID(); + // Non-draft courtScheduleId supplied on nonDefaultDays — simulates the reschedule target + // session (a final, non-draft session assigned by courtscheduler). + final UUID nonDraftCourtScheduleId = UUID.randomUUID(); + + final LocalDate targetDate = ItClock.plusWorkingDays(ItClock.today(), 10); + final ZonedDateTime sessionStart = targetDate.atTime(9, 0).atZone(ZoneOffset.UTC); + + // Stub GET /sessions for the non-draft courtScheduleId. + // fetchCourtSchedulesByIds in the single-day CROWN path calls this after the hearingDay + // has been seeded from nonDefaultDays. isDraft=false must propagate to canAllocateForCrown(). + stubSearchCourtSchedulesByIdSession( + nonDraftCourtScheduleId.toString(), + courtHouseId, + courtRoomId, + targetDate, + sessionStart, + false); + + // Stub POST /hearings (list.hearings-in-sessions) to record the hearing against the session. + stubListHearingInCourtSessionsForCourtSchedule( + hearingId.toString(), + nonDraftCourtScheduleId.toString(), + sessionStart); + + givenAUserHasLoggedInAsAListingOfficer(AbstractIT.USER_ID_VALUE); + final uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps seedSteps = givenARealHearingExists(hearingId); + givenReferenceDataStubsForUpdateHearing(courtCentreId, courtRoomId); + + final Map placeholders = new HashMap<>(); + placeholders.put("%%COURT_CENTRE_ID%%", courtCentreId.toString()); + placeholders.put("%%COURT_ROOM_ID%%", courtRoomId.toString()); + placeholders.put("%%TARGET_DATE%%", targetDate.toString()); + placeholders.put("%%NEW_COURT_SCHEDULE_ID%%", nonDraftCourtScheduleId.toString()); + + final String payload = loadAndSubstitute( + "test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json", + placeholders); + + AbstractIT.restClient.postCommand( + buildUpdateHearingUrl(hearingId), + MEDIA_TYPE_UPDATE_HEARING_FOR_LISTING, + payload, + getLoggedInHeader()); + + // Regression lock: the hearing must be ALLOCATED after the update. + // The single-day CROWN enrichment path resolves the nonDefaultDays courtScheduleId via + // fetchCourtSchedulesByIds; if isDraft=false is returned, canAllocateForCrown() opens and + // hearing-allocated-for-listing-v2 fires. A broken enrichment (stale draft id surviving) + // would leave allocated=false and this poll would time out. + awaitAllocatedProjection(hearingId, targetDate); + seedSteps.verifyPublicEVentHearingChangesSaved(hearingId); + } + + private void awaitAllocatedProjection(final UUID hearingId, final LocalDate expectedStartDate) { + final String url = String.format("%s/%s", getBaseUri(), + MessageFormat.format(readConfig().getProperty("listing.search.hearing"), hearingId.toString())); + RestPollerHelper.pollWithDefaults( + RequestParamsBuilder + .requestParams(url, "application/vnd.listing.search.hearing+json") + .withHeader(HeaderConstants.USER_ID, getLoggedInUser()) + .build()) + .until( + ResponseStatusMatcher.status().is(Status.OK), + ResponsePayloadMatcher.payload() + .isJson(allOf( + JsonPathMatchers.withJsonPath("$.startDate", + is(expectedStartDate.toString())), + JsonPathMatchers.withJsonPath("$.allocated", + is(true))))); + } + + private uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps givenARealHearingExists(final UUID hearingId) { + final uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps seedSteps = + new uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps( + uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsData(hearingId)); + seedSteps.whenCaseIsSubmittedForListing(); + seedSteps.verifyHearingIsCreated(hearingId, 2); + return seedSteps; + } + + private static void givenReferenceDataStubsForUpdateHearing(final UUID courtCentreId, final UUID courtRoomId) { + final CourtCentreData courtCentreData = new CourtCentreData( + courtCentreId, + LocalTime.of(10, 30), + "6:30", + courtRoomId, + "Test Court Centre"); + stubGetReferenceDataCourtCentre(courtCentreData); + stubGetReferenceDataCourtCentreById(courtCentreData); + stubGetReferenceDataCourtMappings(courtCentreData); + stubGetReferenceDataHearingTypes(UUID.randomUUID()); + stubGetReferenceDataOrganisationUnitById(courtCentreId); + } + + private static String loadAndSubstitute(final String classpathResource, + final Map placeholders) throws Exception { + try (InputStream in = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalStateException("Test payload not found on classpath: " + classpathResource); + } + String body = new String(in.readAllBytes()); + for (final Map.Entry e : placeholders.entrySet()) { + body = body.replace(e.getKey(), e.getValue()); + } + return body; + } + } + + private static String buildUpdateHearingUrl(final UUID hearingId) { + final String path = MessageFormat.format( + readConfig().getProperty(UPDATE_HEARING_FOR_LISTING_ENDPOINT_KEY), + hearingId.toString()); + return String.format("%s/%s", getBaseUri(), path); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java index 30aac089f..5e873d51d 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/HearingIT.java @@ -570,7 +570,7 @@ void updateAllocatedHearingWithNoCourtRoomResultsInUnallocatedListing() throws I // Removing the court room must resolve to a DRAFT searchAndBook slot so the aggregate unallocates // and clears the previously-allocated room. whenHearingIsUpdatedForListingHmiEnabled() stubs no // searchAndBook, and the courtRoom-gated stub in whenHearingIsUpdatedForListing only fires when a - // room is present — so without this the /searchlist/hearingslots call finds no slot, enrichment + // room is present — so without this the POST /hearings/{id} (mags.search.and.book) call finds no slot, enrichment // no-ops, and the original allocation (court room) survives → assertion sees a UUID, not null. stubSearchBookHearingSlotsForCrownDraft(hearinId.toString(), courtCentreId.toString()); updateHearingSteps.whenHearingIsUpdatedForListingHmiEnabled(); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java new file mode 100644 index 000000000..0e504871c --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -0,0 +1,203 @@ +package uk.gov.moj.cpp.listing.it; + +import static java.util.UUID.randomUUID; +import static javax.ws.rs.core.Response.Status.ACCEPTED; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static uk.gov.moj.cpp.listing.helper.SearchHearingHelper.pollUntilHearingIsPresent; +import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessions; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubProvisionalBookingWithCustomParams; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled; + +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; +import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.Test; + +/** + * Covers listing.command.move-hearing-to-past-date: MAGISTRATES wraps courtscheduler synchronously + * and stores the returned slot as enrichment; CROWN is listing-side-only (Baris decision D1) and + * never calls courtscheduler. Single-day only. + */ +class MoveHearingToPastDateIT extends AbstractIT { + + private static final String COURT_ROOM_ID = "731816c1-27ea-4711-8d92-0a1c2f3ab7de"; + + /** + * Lists a real hearing through the full flow (command → events → viewstore projection) and only + * returns once it is queryable — the move command's HEARING_ID_NOT_FOUND pre-check reads the + * viewstore, so moving an un-listed hearing is legitimately rejected. Mirrors VacateHearingIT: + * MAGS listing needs the provisional-booking + list-hearing-in-court-sessions stubs; CROWN + * listing never calls courtscheduler pre-Phase-2. + */ + private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction) { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(jurisdiction); + final ListCourtHearingSteps listCourtHearingSteps = new ListCourtHearingSteps(hearingsData); + + if (MAGISTRATES_JURISDICTION.equals(jurisdiction)) { + final ZonedDateTime hearingStartTime = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getHearingStartTime(); + final UUID courtCentreId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtCentreId(); + final UUID courtroomId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtRoomId(); + final String listedCourtScheduleId = randomUUID().toString(); + + final Map stubParams = new HashMap<>(); + stubParams.put("SESSION_DATE", hearingStartTime.toLocalDate().toString()); + stubParams.put("COURT_CENTRE_ID", courtCentreId.toString()); + stubParams.put("COURT_SCHEDULE_ID", listedCourtScheduleId); + stubParams.put("COURT_ROOM_ID", courtroomId.toString()); + stubParams.put("BOOKING_ID", randomUUID().toString()); + stubParams.put("HEARING_START_TIME", hearingStartTime.toString()); + stubProvisionalBookingWithCustomParams(stubParams); + stubListHearingInCourtSessions(listCourtHearingSteps.getHearingsData().getHearingData().get(0).getId().toString(), + listedCourtScheduleId, hearingStartTime); + } + + listCourtHearingSteps.whenCaseIsSubmittedForListing(); + listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED); + // verifyHearingListedFromAPI's indefinite json-path filters have no result matcher, so + // they match vacuously against an empty hearings list - it can return before THIS hearing + // is projected. Poll on the hearing id (hasSize(1)) so the move command's viewstore + // pre-check cannot race the hearing-listed projection and 422 with HEARING_ID_NOT_FOUND. + pollUntilHearingIsPresent(hearingsData.getHearingData().get(0).getCourtCentreId().toString(), + ALLOCATED, getLoggedInUser().toString(), hearingsData.getHearingData().get(0).getId().toString()); + + return new MoveHearingToPastDateSteps(hearingsData); + } + + @Test + void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + final LocalDate pastDate = ItClock.today().minusDays(1); + final String courtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateCalled(moveSteps.getHearingId()); + moveSteps.verifyCourtScheduleStored(courtScheduleId); + } + + @Test + void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final String firstCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), firstCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(firstCourtScheduleId); + + final String secondCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), secondCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(secondCourtScheduleId); + } + + @Test + void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED", + "Hearings can only be moved to today or an earlier date"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldRejectMagistratesMoveWith422WhenNoCourtScheduleSessionExists() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "NO_SESSION_FOUND", + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); + } + + /** Older courtscheduler releases signalled no-session as a bare 404 - the listing adapter + * normalises that to the 422 NO_SESSION_FOUND contract. */ + @Test + void shouldNormaliseLegacyCourtscheduler404ToA422NoSessionFound() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); + } + + @Test + void shouldRejectMoveWith422WhenHearingIdUnknown() { + // A hearing that was never listed - MoveHearingToPastDateSteps still needs SOME allocated + // hearing to obtain a courtCentreId, but we submit against a random unknown hearingId. + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final UUID unknownHearingId = randomUUID(); + + final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("HEARING_ID_NOT_FOUND")); + verifyMoveHearingToPastDateNeverCalled(unknownHearingId.toString()); + } + + @Test + void shouldRejectMoveWith400WhenMandatoryFieldMissing() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(400)); + } + + @Test + void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + moveSteps.verifyStartDateUpdated(pastDate); + } + + @Test + void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java index 92187d47c..31c385889 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/ListCourtHearingSteps.java @@ -439,9 +439,10 @@ protected void stubReferenceDataForFirstHearing() { /** * For a CROWN hearing the {@code bookingReference} IS the courtScheduleId. The listing command resolves - * it against courtscheduler ({@code search.court-schedules-by-id}) and then lists it - * ({@code list.hearings-in-court-sessions}). Stub both so the bookingReference resolves to a session - * echoing this hearing's own centre/room — keeping the enriched hearing consistent with the listed values. + * it against courtscheduler (GET {@code /sessions?ids=} — was {@code /courtschedule/search.court-schedules-by-id}) + * and then lists it (POST {@code /hearings} — was {@code list.hearings-in-court-sessions}). + * Stub both so the bookingReference resolves to a session echoing this hearing's own centre/room — + * keeping the enriched hearing consistent with the listed values. * No-op for MAGISTRATES, unallocated hearings (no booking reference) or hearings without a court centre. */ private static void stubCrownBookingReferenceResolution(final HearingData hearingData, final UUID bookingReference) { @@ -465,11 +466,12 @@ private static void stubCrownBookingReferenceResolution(final HearingData hearin /** * Booked-slot analogue of {@link #stubCrownBookingReferenceResolution}: a CROWN hearing listed with * pre-booked slots carries the chosen courtScheduleId on {@code bookedSlots[]}, which the listing command - * resolves against courtscheduler ({@code search.court-schedules-by-id}) and then lists - * ({@code list.hearings-in-court-sessions}). Stub both so each bookedSlot's courtScheduleId resolves to a - * non-draft session echoing this hearing's own centre/room — keeping the enriched hearing consistent with - * the listed values. Without these the enrichment degrades to the legacy bookedSlots fallback and logs a - * failed courtscheduler retrieve. No-op for MAGISTRATES or hearings without booked slots. + * resolves against courtscheduler (GET {@code /sessions?ids=} — was {@code /courtschedule/search.court-schedules-by-id}) + * and then lists (POST {@code /hearings} — was {@code list.hearings-in-court-sessions}). + * Stub both so each bookedSlot's courtScheduleId resolves to a non-draft session echoing this hearing's + * own centre/room — keeping the enriched hearing consistent with the listed values. Without these + * the enrichment degrades to the legacy bookedSlots fallback and logs a failed courtscheduler retrieve. + * No-op for MAGISTRATES or hearings without booked slots. */ private static void stubCrownBookedSlotResolution(final HearingData hearingData) { if (!"CROWN".equals(hearingData.getJurisdictionType()) || !isNotEmpty(hearingData.getBookedSlots())) { @@ -1639,8 +1641,9 @@ private ListCourtHearing getListCourtHearingData(final HearingsData hearingsData // Determine if hearing is allocated (has court room) or unallocated final boolean isAllocated = hearingData.getCourtRoomId() != null; // CROWN treats the bookingReference as the courtScheduleId; the command resolves it via - // search.court-schedules-by-id. Stub that resolution (and the follow-up list call) to echo - // this hearing's own centre/room so the resolved session matches the listed values. + // GET /sessions?ids= (was search.court-schedules-by-id). Stub that resolution (and the + // follow-up list call to POST /hearings) to echo this hearing's own centre/room so the + // resolved session matches the listed values. final UUID bookingReference = isAllocated ? randomUUID() : null; stubCrownBookingReferenceResolution(hearingData, bookingReference); @@ -2061,8 +2064,8 @@ private ListCourtHearing getListForCourtHearingData(final HearingsData hearingsD .map(offence -> offence.getOffenceId()) .collect(Collectors.toList()); - // CROWN: resolve the bookingReference (= courtScheduleId) via search.court-schedules-by-id; stub it to - // echo this hearing's own centre/room so the resolved session matches the listed values. + // CROWN: resolve the bookingReference (= courtScheduleId) via GET /sessions?ids= (was search.court-schedules-by-id); + // stub it to echo this hearing's own centre/room so the resolved session matches the listed values. final UUID bookingReference = randomUUID(); stubCrownBookingReferenceResolution(hearingData, bookingReference); diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java new file mode 100644 index 000000000..4751a6baf --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -0,0 +1,109 @@ +package uk.gov.moj.cpp.listing.steps; + +import static com.jayway.jsonpath.matchers.JsonPathMatchers.withJsonPath; +import static java.text.MessageFormat.format; +import static javax.ws.rs.core.Response.Status.OK; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.justice.services.common.http.HeaderConstants.USER_ID; +import static uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder.requestParams; +import static uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher.payload; +import static uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher.status; +import static uk.gov.moj.cpp.listing.it.util.RestPollerHelper.pollWithDefaults; +import static uk.gov.moj.cpp.listing.utils.FileUtil.getPayload; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; + +import uk.gov.moj.cpp.listing.it.AbstractIT; +import uk.gov.moj.cpp.listing.steps.data.HearingData; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +/** + * Steps for the listing.command.move-hearing-to-past-date wrapper endpoint. Same + * {@code POST /hearings/{hearingId}} resource as vacate-trial/extend-hearing, distinguished by + * media type {@code application/vnd.listing.command.move-hearing-to-past-date+json}. + */ +public class MoveHearingToPastDateSteps extends AbstractIT { + + private static final String LISTING_QUERY_HEARING = "listing.search.hearing"; + private static final String MEDIA_TYPE_SEARCH_HEARING = "application/vnd.listing.search.hearing+json"; + private static final String LISTING_COMMAND_MOVE = "listing.command.move-hearing-to-past-date"; + private static final String MEDIA_TYPE_MOVE = "application/vnd.listing.command.move-hearing-to-past-date+json"; + + private final String hearingId; + private final UUID courtCentreId; + + public MoveHearingToPastDateSteps(final HearingsData hearingsData) { + final HearingData hearingData = hearingsData.getHearingData().get(0); + this.hearingId = hearingData.getId().toString(); + this.courtCentreId = hearingData.getCourtCentreId(); + givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); + } + + public String getHearingId() { + return hearingId; + } + + public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) { + final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json") + .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString()) + .replace("%%START_DATE%%", date.toString()); + + return postMove(payload); + } + + public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { + final String payload = "{\"startDate\":\"" + date + "\"}"; + return postMove(hearingId, payload); + } + + /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this + * steps' own courtCentreId so only the hearingId lookup is exercised. The target hearing is + * identified purely by the URL path - hearingId is not part of the body. */ + public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) { + final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startDate\":\"" + date + "\"}"; + return postMove(otherHearingId.toString(), payload); + } + + private Response postMove(final String payload) { + return postMove(hearingId, payload); + } + + private Response postMove(final String targetHearingId, final String payload) { + final String url = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_COMMAND_MOVE), targetHearingId)); + return restClient.postCommand(url, MEDIA_TYPE_MOVE, payload, getLoggedInHeader()); + } + + public void verifyCourtScheduleStored(final String expectedCourtScheduleId) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.hearingDays[*].courtScheduleId", hasItem(expectedCourtScheduleId)) + ))); + } + + public void verifyStartDateUpdated(final LocalDate expectedStartDate) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.startDate", is(expectedStartDate.toString())), + withJsonPath("$.hearingDays[0].hearingDate", is(expectedStartDate.toString())) + ))); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java index 760ad92d5..77a01c0a1 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java @@ -2,12 +2,15 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.put; import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static java.lang.String.format; import static java.util.concurrent.TimeUnit.SECONDS; @@ -54,9 +57,16 @@ public class CourtSchedulerServiceStub { private static final String PROVISIONAL_BOOKING = "/provisionalBooking"; private static final String HEARING_SLOTS = "/hearingslots"; private static final String VALIDATE_SESSION_AVAILABILITY = "/validate-session-availability"; - private static final String SEARCH_COURT_SCHEDULES_BY_ID = "/courtschedule/search.court-schedules-by-id"; - private static final String CROWN_FALLBACK_SEARCH_BOOK = "/crownfallbacksearchandbook/hearingslots"; - private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = "application/vnd.courtscheduler.crown.fallback.search.book.hearing.slots+json"; + private static final String SEARCH_COURT_SCHEDULES_BY_ID = "/sessions"; + private static final String SESSIONS_PATH = "/sessions"; + private static final String HEARINGS_PATH = "/hearings"; + /** Content-type used by the new POST /hearings/{id} crown search-and-book endpoint. */ + private static final String CROWN_SEARCH_AND_BOOK_TYPE = "application/vnd.courtscheduler.crown.search.and.book+json"; + /** Content-type used by the new POST /hearings/{id} mags search-and-book endpoint. */ + private static final String MAGS_SEARCH_AND_BOOK_TYPE = "application/vnd.courtscheduler.mags.search.and.book+json"; + /** Kept for backward-compat constant name; body of crown-fallback stub now uses CROWN_SEARCH_AND_BOOK_TYPE. */ + private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = CROWN_SEARCH_AND_BOOK_TYPE; + private static final String MOVE_HEARING_TO_PAST_DATE_TYPE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json"; private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json"; public static final String COURTSCHEDULER_GET_PROVISIONAL_BOOKING_TYPE = "application/vnd.courtscheduler.get.provisional.booking+json"; @@ -82,13 +92,17 @@ public static void stubUpdateAvailableHearingSlotsService() { .willReturn(aResponse().withStatus(NO_CONTENT.getStatusCode()))); } + /** Stub DELETE /sessions/{hearingId} — release a booked session (was DELETE /hearingslots/{id}). */ public static void stubDeleteAvailableHearingSlotsService(final String hearingId) { - stubFor(WireMock.delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/" + hearingId)) + stubFor(delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/" + hearingId)) + .withHeader(CONTENT_TYPE, containing("application/vnd.courtscheduler.release.sessions+json")) .willReturn(aResponse().withStatus(ACCEPTED.getStatusCode()))); } + /** Stub DELETE /sessions/.* for any hearingId — release a booked session (was DELETE /hearingslots/.*). */ public static void stubDeleteAvailableHearingSlotsServiceForAnyHearing() { - stubFor(WireMock.delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/.*")) + stubFor(delete(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing("application/vnd.courtscheduler.release.sessions+json")) .willReturn(aResponse().withStatus(ACCEPTED.getStatusCode()))); } @@ -102,7 +116,7 @@ public static void verifyDeleteAvailableHearingSlotsStubCommandIsNeverInvoked(fi private static void verifyDeleteAvailableHearingSlotsStubCommandInvokedNTimes(final String hearingId, final int invocationCount) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { - final RequestPatternBuilder requestPatternBuilder = WireMock.deleteRequestedFor(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + CourtSchedulerServiceStub.HEARING_SLOTS + "/" + hearingId)); + final RequestPatternBuilder requestPatternBuilder = WireMock.deleteRequestedFor(urlPathMatching(CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH + "/" + hearingId)); try { WireMock.verify(WireMock.exactly(invocationCount), requestPatternBuilder); } catch (VerificationException e) { @@ -137,12 +151,10 @@ public static void stubValidateSessionAvailability() { } /** - * Stub a successful response from /courtschedule/search.court-schedules-by-id for the given - * courtScheduleId. Returned wire shape mirrors what the real courtscheduler emits via + * Stub a successful response from GET /sessions (was /courtschedule/search.court-schedules-by-id) + * for the given courtScheduleId. Returned wire shape mirrors what the real courtscheduler emits via * {@code CourtSchedulerApi.searchCourtSchedulesById} - FLAT: each courtSchedules[] element - * is a single CourtSchedule with isDraft at the top level. The schema example in - * courtscheduler-api shows a misleading nested "sessions" structure copied from a different - * endpoint; never match the schema shape, match the wire shape. + * is a single CourtSchedule with isDraft at the top level. * * @param courtScheduleId the id under query * @param isDraft draft state to report - drives whether @@ -150,7 +162,7 @@ public static void stubValidateSessionAvailability() { * {@code anyDraft=true} (strip) or {@code anyDraft=false} (preserve) */ /** - * Stub courtscheduler's search-court-schedules-by-id response with an explicit choice of + * Stub courtscheduler's GET /sessions (search-by-id) response with an explicit choice of * draft-field name. Real-world Jackson serialisation of CourtSchedule emits one of: * - {@code "isDraft": } (from the setter convention) * - {@code "draft": } (from the boolean-getter "is" prefix stripping) @@ -165,7 +177,7 @@ public static void stubSearchCourtSchedulesByIdWithKey(final String courtSchedul final boolean draft) { final String body = "{\"courtSchedules\":[{\"courtScheduleId\":\"" + courtScheduleId + "\",\"" + draftKey + "\":" + draft + "}]}"; - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -173,11 +185,11 @@ public static void stubSearchCourtSchedulesByIdWithKey(final String courtSchedul } /** - * Stub /courtschedule/search.court-schedules-by-id to return a 500. Exercises the listing + * Stub GET /sessions (was /courtschedule/search.court-schedules-by-id) to return a 500. Exercises the listing * adapter's fail-closed path (anyDraft=true on courtscheduler error). */ public static void stubSearchCourtSchedulesByIdServerError() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .willReturn(aResponse().withStatus(500) .withBody("internal server error") .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -185,10 +197,11 @@ public static void stubSearchCourtSchedulesByIdServerError() { } /** - * Stub {@code search.court-schedules-by-id} so a CROWN bookingReference (which IS the courtScheduleId) - * resolves to a single session echoing the supplied courtHouse / room / date. The listing command resolves - * the bookingReference here (see {@code CourtScheduleEnrichmentService.promoteCrownBookingReferenceToBookedSlot}). - * Scoped by the {@code courtScheduleIds} query param so it answers only for this hearing's bookingReference + * Stub GET /sessions (was /courtschedule/search.court-schedules-by-id) so a CROWN bookingReference + * (which IS the courtScheduleId) resolves to a single session echoing the supplied courtHouse / room / date. + * The listing command resolves the bookingReference here + * (see {@code CourtScheduleEnrichmentService.promoteCrownBookingReferenceToBookedSlot}). + * Scoped by the {@code ids} query param so it answers only for this hearing's bookingReference * and never pollutes other tests (WireMock stubs persist across IT classes in a suite). */ public static void stubSearchCourtSchedulesByIdSession(final String courtScheduleId, @@ -214,18 +227,23 @@ public static void stubSearchCourtSchedulesByIdSession(final String courtSchedul session.append(",\"isDraft\":").append(isDraft).append("}"); final String body = "{\"courtSchedules\":[" + session + "]}"; - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + SEARCH_COURT_SCHEDULES_BY_ID))) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .atPriority(2) - .withQueryParam("courtScheduleIds", containing(courtScheduleId)) + .withQueryParam("ids", containing(courtScheduleId)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - // --- Crown fallback search-and-book stubs (Option C: courtCentreId-only wire) --- + // --- Crown fallback search-and-book stubs (POST /hearings/{hearingId}, crown.search.and.book, durationInMinutes <= 360) --- /** - * Stub a successful 200 response from /crownfallbacksearchandbook/hearingslots. + * Stub a successful 200 response from POST /hearings/{hearingId} with content-type + * {@code crown.search.and.book} for the single-day (fallback) path. + * Discriminated from the multi-day stub by {@code durationInMinutes <= 360} body matcher. + * Response carries flat top-level fields (hearingId, courtScheduleId, source, + * courtRoomId, sessionDate, sessionStartTime, sessionEndTime, durationInMinutes, isDraft, + * businessType, overbooked) plus {@code "sessions":[]} (empty, single-day shape). * * @param hearingId hearingId echoed back in the response * @param courtScheduleId the booked session id to return (as if courtscheduler picked it) @@ -243,40 +261,50 @@ public static void stubCrownFallbackSearchAndBookSuccess(final String hearingId, "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":731816," + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + "\"durationInMinutes\":10,\"isDraft\":%s,\"businessType\":\"CR\"," + - "\"source\":\"%s\",\"overbooked\":false}", + "\"source\":\"%s\",\"overbooked\":false,\"sessions\":[]}", hearingId, courtScheduleId, sessionDate, startTime, endTime, isDraft, source); - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) - .withQueryParam("source", matching(source)) + // durationInMinutes <= 360 distinguishes single-day (crown fallback) from multi-day + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"source\":\"" + source + "\"")) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) - .withHeader(CONTENT_TYPE, CROWN_FALLBACK_SEARCH_BOOK_TYPE))); + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub 404 "no session found" — listing-side translates to CrownFallbackNoSessionException. */ + /** Stub 404 "no session found" on POST /hearings/{id} (crown.search.and.book, single-day) + * — listing-side translates to CrownFallbackNoSessionException. */ public static void stubCrownFallbackSearchAndBookNotFound() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse() .withStatus(404) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub 400 "invalid request" — used for defensive coverage; the listing-side multi-day guard fires first. */ + /** Stub 400 "invalid request" on POST /hearings/{id} (crown.search.and.book, single-day) + * — used for defensive coverage; the listing-side multi-day guard fires first. */ public static void stubCrownFallbackSearchAndBookBadRequest() { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("[0-9]|[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|360"))) .willReturn(aResponse() .withStatus(BAD_REQUEST.getStatusCode()) .withBody("{\"error\":\"durationInMinutes exceeds single-day cap\"}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Verify the Crown fallback endpoint was called with the expected source label. */ + /** Verify POST /hearings/{id} (crown.search.and.book) was called with the expected source label in the body. */ public static void verifyCrownFallbackSearchAndBookCalledWithSource(final String source) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK)) - .withQueryParam("source", matching(source))); + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"source\":\"" + source + "\""))); return true; } catch (VerificationException e) { return false; @@ -284,19 +312,23 @@ public static void verifyCrownFallbackSearchAndBookCalledWithSource(final String }); } - /** Verify the Crown fallback endpoint was NEVER called (regression guard for MAGS / already-allocated CROWN). */ + /** Verify POST /hearings/{id} (crown.search.and.book) was NEVER called + * (regression guard for MAGS / already-allocated CROWN). */ public static void verifyCrownFallbackSearchAndBookNeverCalled() { - WireMock.verify(0, WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + CROWN_FALLBACK_SEARCH_BOOK))); + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE))); } - // --- Multi-day search-and-book stubs (CROWN update multi-day path) --- + // --- Multi-day search-and-book stubs (POST /hearings/{hearingId}, crown.search.and.book, durationInMinutes > 360) --- /** - * Stub a successful response from GET /multidaysearchandbook/hearingslots returning the supplied - * court schedule sessions. Used to drive the CROWN multi-day update path — the listing service - * passes the starting courtScheduleId + total duration, courtscheduler returns N consecutive - * sessions that together cover the duration. + * Stub a successful response from POST /hearings/{hearingId} (crown.search.and.book) returning the + * supplied court schedule sessions under the key {@code "sessions"} (was {@code "courtSchedules"} + * on the old GET /multidaysearchandbook/hearingslots endpoint). Used to drive the CROWN multi-day + * update path — the listing service passes the starting courtScheduleId + total duration, courtscheduler + * returns N consecutive sessions that together cover the duration. + * Discriminated from the crown-fallback single-day stub by {@code durationInMinutes > 360}. */ public static void stubMultiDaySearchAndBook(final List courtScheduleIds, final UUID courtHouseId, @@ -304,7 +336,7 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds final LocalDate firstSessionDate, final boolean isDraft) { final StringBuilder body = new StringBuilder(); - body.append("{\"courtSchedules\":["); + body.append("{\"sessions\":["); for (int i = 0; i < courtScheduleIds.size(); i++) { if (i > 0) { body.append(","); @@ -321,7 +353,12 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds } body.append("]}"); - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))) + // durationInMinutes > 360 distinguishes multi-day from single-day (crown fallback) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("3[6-9][1-9]|[4-9][0-9]{2}|[1-9][0-9]{3,}"))) + .withRequestBody(matchingJsonPath("$.courtCentreId")) + .withRequestBody(matchingJsonPath("$.hearingDate")) .atPriority(1) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body.toString()) @@ -329,17 +366,18 @@ public static void stubMultiDaySearchAndBook(final List courtScheduleIds } /** - * Verify that GET /multidaysearchandbook/hearingslots was called with the expected courtScheduleId - * + total duration. Proves the CROWN update path correctly routed multi-day through the CourtSchedule-first - * flow and didn't regress to the startDate→endDate expansion. + * Verify that POST /hearings/{id} (crown.search.and.book) was called with the expected courtScheduleId + * + total duration in the request body. Proves the CROWN update path correctly routed multi-day through + * the CourtSchedule-first flow and didn't regress to the startDate→endDate expansion. */ public static void verifyMultiDaySearchAndBookCalled(final String courtScheduleId, final int durationInMinutes) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots")) - .withQueryParam("courtScheduleId", WireMock.equalTo(courtScheduleId)) - .withQueryParam("durationInMinutes", WireMock.equalTo(String.valueOf(durationInMinutes)))); + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"courtScheduleId\":\"" + courtScheduleId + "\"")) + .withRequestBody(containing("\"durationInMinutes\":" + durationInMinutes))); return true; } catch (VerificationException e) { return false; @@ -347,28 +385,31 @@ public static void verifyMultiDaySearchAndBookCalled(final String courtScheduleI }); } - /** Regression guard: CROWN update without a courtScheduleId must NOT trigger multi-day search-and-book. */ + /** Regression guard: CROWN update without a courtScheduleId must NOT trigger multi-day search-and-book + * via POST /hearings/{id} (crown.search.and.book). */ public static void verifyMultiDaySearchAndBookNeverCalled() { - WireMock.verify(0, WireMock.getRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))); + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(matchingJsonPath("$.durationInMinutes", matching("3[6-9][1-9]|[4-9][0-9]{2}|[1-9][0-9]{3,}")))); } // --- Extend multi-day hearing stubs (SPRDT-901: CROWN update-hearing-for-listing multi-day path) --- + // Endpoint reshaped: was POST /extendmultidayhearing/hearingslots, now PATCH /hearings/{hearingId}. - private static final String EXTEND_MULTIDAY = "/extendmultidayhearing/hearingslots"; private static final String COURTSCHEDULER_EXTEND_MULTIDAY_TYPE = "application/vnd.courtscheduler.extend.multiday.hearing+json"; /** - * Stub a successful POST to /extendmultidayhearing/hearingslots returning the supplied court schedule - * sessions, scoped to the supplied hearingId. SPRDT-901 routes CROWN multi-day updates here instead of - * the GET-based /multidaysearchandbook — courtscheduler receives the full duration and returns N - * sessions to use as the rebuilt hearingDays. + * Stub a successful PATCH to /hearings/{hearingId} (extend.multiday.hearing) returning the supplied + * court schedule sessions under key {@code "courtSchedules"}, scoped to the supplied hearingId. + * SPRDT-901 routes CROWN multi-day updates here instead of the old GET-based endpoint. + * Courtscheduler receives the full duration and returns N sessions to use as the rebuilt hearingDays. * *

Scoping: WireMock stubs persist across IT classes in the same suite. Without a body * matcher, this stub would intercept every other IT that extends a CROWN hearing into multi-day * (e.g. HearingCsvReportIT) and return these synthetic courtSchedules β€” corrupting their hearingDays. - * The hearingId body match makes the stub apply only to the test's own hearing. + * The hearingId in the path makes the stub apply only to the test's own hearing. */ public static void stubExtendMultiDayHearing(final String hearingId, final List courtScheduleIds, @@ -383,13 +424,10 @@ public static void stubExtendMultiDayHearing(final String hearingId, body.append(","); } final LocalDate sessionDate = firstSessionDate.plusDays(i); - // Wire-shape note: the courtscheduler RAML example for this endpoint - // (courtscheduler.extend.multiday.hearing.response.json) shows "hearingStartTime", but the - // real endpoint serialises the courtscheduler DOMAIN CourtSchedule, which only has - // "sessionStartTime" (java.util.Date). Listing's buildHearingDaysFromMultiDaySessions reads - // getSessionStartTime() β€” emitting the RAML's field name leaves HearingDay.startTime null - // and NPEs downstream ("HearingDay.getStartTime() is null"). Match the wire, not the schema. - // (stubMultiDaySearchAndBook above is DIFFERENT: that endpoint's consumer reads hearingStartTime.) + // Wire-shape note: the courtscheduler endpoint serialises the domain CourtSchedule, which + // only has "sessionStartTime". Listing's buildHearingDaysFromMultiDaySessions reads + // getSessionStartTime() β€” emitting "hearingStartTime" leaves HearingDay.startTime null. + // Match the wire, not the old RAML example. Response key stays "courtSchedules" (unchanged). body.append("{") .append("\"courtScheduleId\":\"").append(courtScheduleIds.get(i)).append("\",") .append("\"courtHouseId\":\"").append(courtHouseId).append("\",") @@ -401,7 +439,7 @@ public static void stubExtendMultiDayHearing(final String hearingId, } body.append("]}"); - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))) + stubFor(patch(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) @@ -410,15 +448,15 @@ public static void stubExtendMultiDayHearing(final String hearingId, } /** - * Verify that POST /extendmultidayhearing/hearingslots was called with a body containing the + * Verify that PATCH /hearings/{id} (extend.multiday.hearing) was called with a body containing the * supplied hearingId and durationInMinutes. Proves SPRDT-901 routing: the CROWN multi-day update - * was sent to courtscheduler's new extension endpoint with the full requested duration. + * was sent to courtscheduler's reshape endpoint with the full requested duration. */ public static void verifyExtendMultiDayHearingCalled(final String hearingId, final int durationInMinutes) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { - WireMock.verify(WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY)) + WireMock.verify(WireMock.patchRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+")) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .withRequestBody(containing("\"durationInMinutes\":" + durationInMinutes))); @@ -429,14 +467,15 @@ public static void verifyExtendMultiDayHearingCalled(final String hearingId, fin }); } - /** Regression guard: single-day CROWN updates / non-CROWN updates must NOT call /extendmultidayhearing. */ + /** Regression guard: single-day CROWN updates / non-CROWN updates must NOT call PATCH /hearings/{id} + * (extend.multiday.hearing). */ public static void verifyExtendMultiDayHearingNeverCalled() { - WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))); + WireMock.verify(0, WireMock.patchRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))); } /** - * SPRDT-902: stub a 422 typed-failure response from /extendmultidayhearing/hearingslots, + * SPRDT-902: stub a 422 typed-failure response from PATCH /hearings/{hearingId} (extend.multiday.hearing), * scoped to a specific hearingId. Body shape mirrors the courtscheduler RAML error contract. */ public static void stubExtendMultiDayHearingFailure(final String hearingId, @@ -456,7 +495,7 @@ public static void stubExtendMultiDayHearingFailure(final String hearingId, } body.append("}"); - stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + EXTEND_MULTIDAY))) + stubFor(patch(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) .withHeader(CONTENT_TYPE, containing(COURTSCHEDULER_EXTEND_MULTIDAY_TYPE)) .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(statusCode) @@ -821,9 +860,10 @@ public static void stubListHearingInCourtSessions(final String hearingId, final " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -848,9 +888,9 @@ public static void stubListHearingInCourtSessionsForCourtSchedule(final String h " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .atPriority(2) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing(courtScheduleId)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) @@ -870,9 +910,10 @@ public static void stubListHearingInCourtSessions(final String hearingId, final " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -931,9 +972,10 @@ public static void stubListHearingInCourtSessionsWithJudiciary(final String hear payload.append(" ]\n"); payload.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -958,9 +1000,10 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Str " ]\n" + "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1039,9 +1082,10 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedules(final Upd hearingsJson.append("\n ]\n"); hearingsJson.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1119,9 +1163,10 @@ public static void stubListHearingInCourtSessionsWithMultipleSchedulesWithJudici hearingsJson.append("\n ]\n"); hearingsJson.append("}"); - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(hearingsJson.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1158,139 +1203,140 @@ public static void stubListHearingInCourtSessionsForProvisionalBooking(final Str "}"; - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) - .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-court-sessions+json")) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) + .withHeader("content-type", containing("application/vnd.courtscheduler.list.hearings-in-sessions+json")) .withRequestBody(containing("hearingSlots")) + .withRequestBody(containing("courtScheduleId")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } - public static void stubSearchBookHearingSlots(final String hearingId, final String courtCentreId, final String hearingDate,final ZonedDateTime hearingStartTime) { + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) β€” was GET /searchlist/hearingslots. + * Response reshaped: old {@code hearingSlots{}} object replaced by + * {@code {hearingId, sessions:[{courtScheduleId, courtRoomId, sessionStartTime, draft, businessType, judiciaries:[]}]}}. + * Fields from the old hearingSlots object are moved into sessions[0]. + */ + public static void stubSearchBookHearingSlots(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = hearingStartTime.toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtCentreId + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + hearingStartTime.toString() + "\",\n" + - " \"duration\": 20\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"MC\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) -// .withQueryParam("hearingId", WireMock.matching(".*")) - .withQueryParam("hearingId", matching(hearingId)) -// .withQueryParam("courtCentreId", WireMock.matching(".*")) - .withQueryParam("courtCentreId", matching(courtCentreId)) -// .withQueryParam("hearingDate", WireMock.matching(".*")) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("hearingSessionDateSearchCutOff", matching(hearingDate)) -// .withQueryParam("hearingStartTime", WireMock.matching(hearingStartTime.toString())) - .withQueryParam("durationInMinutes", matching("20")) - .withQueryParam("isPolice", matching("true|false")) + // Request body now carries the params (hearingId, courtCentreId, hearingDate, etc.) + // Match on hearingId in request body + content-type; stub scoped to this hearing + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) with businessType variant. + * Response reshaped to sessions[] shape; courtRoomId and sessionStartTime carried in sessions[0]. + */ public static void stubSearchBookHearingSlotsWithBusinessType(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime, final String businessType, final String courtRoomId, final Integer durationInMinutes) { + final String resolvedRoomId = courtRoomId != null ? courtRoomId : courtCentreId; + final String sessionStartTime = hearingStartTime != null ? hearingStartTime.toString() : ""; + final String courtScheduleId = UUID.randomUUID().toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + - " \"courtRoomId\": \"" + (courtRoomId != null ? courtRoomId : courtCentreId) + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + hearingStartTime.toString() + "\",\n" + - " \"duration\": " + (durationInMinutes != null ? durationInMinutes : 20) + "\n" + - " }\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + + " \"courtRoomId\": \"" + resolvedRoomId + "\",\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"" + businessType + "\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - com.github.tomakehurst.wiremock.client.MappingBuilder mappingBuilder = get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("businessType", matching(businessType)) - .withQueryParam("durationInMinutes", matching(String.valueOf(durationInMinutes != null ? durationInMinutes : 20))) - .withQueryParam("isPolice", matching("true|false")); - - if (courtRoomId != null) { - mappingBuilder = mappingBuilder.withQueryParam("courtRoomId", matching(courtRoomId)); - } - if (hearingStartTime != null) { - mappingBuilder = mappingBuilder.withQueryParam("hearingStartTime", matching(hearingStartTime.toString())); - } - - stubFor(mappingBuilder + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) + .withRequestBody(containing("\"businessType\":\"" + businessType + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) for draft sessions. + * Response uses sessions[] shape with {@code "draft":true} in sessions[0]. + */ public static void stubSearchBookHearingSlotsForDraftSessions(final String hearingId, final String courtCentreId, final String hearingDate, final ZonedDateTime hearingStartTime, final String courtRoomId, final Integer durationInMinutes) { + final String resolvedRoomId = courtRoomId != null ? courtRoomId : courtCentreId; + final String sessionStartTime = hearingStartTime != null ? hearingStartTime.toString() : ""; + final String courtScheduleId = UUID.randomUUID().toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + - " \"courtRoomId\": \"" + (courtRoomId != null ? courtRoomId : courtCentreId) + "\",\n" + - " \"hearingDate\": \"" + hearingDate + "\",\n" + - " \"hearingSessionDateSearchCutOff\": \"" + hearingDate + "\",\n" + - " \"hearingStartTime\": \"" + (hearingStartTime != null ? hearingStartTime.toString() : "") + "\",\n" + - " \"duration\": " + (durationInMinutes != null ? durationInMinutes : 20) + ",\n" + - " \"isDraft\": true\n" + - " }\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + + " \"courtRoomId\": \"" + resolvedRoomId + "\",\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": true,\n" + + " \"businessType\": \"MC\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - com.github.tomakehurst.wiremock.client.MappingBuilder mappingBuilder = get(WireMock.urlPathEqualTo(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withHeader("Accept", containing("application/vnd.courtscheduler.search.book.hearing.slots+json")) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) - .withQueryParam("hearingDate", matching(hearingDate)) - .withQueryParam("durationInMinutes", matching(String.valueOf(durationInMinutes != null ? durationInMinutes : 20))) - .withQueryParam("isPolice", matching("true|false")); - - if (courtRoomId != null) { - mappingBuilder = mappingBuilder.withQueryParam("courtRoomId", matching(courtRoomId)); - } - if (hearingStartTime != null) { - mappingBuilder = mappingBuilder.withQueryParam("hearingStartTime", matching(hearingStartTime.toString())); - } - - stubFor(mappingBuilder + stubFor(post(urlPathMatching(format("%s", CourtSchedulerServiceStub.COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + /** + * Stub POST /hearings/{hearingId} (mags.search.and.book) for CROWN path + * (was GET /searchlist/hearingslots without Accept header restriction). + * Response uses sessions[] shape. + */ public static void stubSearchBookHearingSlotsForCrown(final String hearingId, final String courtCentreId, final String courtRoomId) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0).toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtRoomId + "\",\n" + - " \"hearingDate\": \"" + ItClock.today().plusDays(5) + "\",\n" + - " \"hearingStartTime\": \"" + ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0) + "\",\n" + - " \"duration\": 30\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": false,\n" + + " \"businessType\": \"CR\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1298,30 +1344,30 @@ public static void stubSearchBookHearingSlotsForCrown(final String hearingId, fi } /** - * searchAndBook stub for the CROWN "update removing the court room β†’ unallocated" scenario. - * Mirrors {@link #stubSearchBookHearingSlotsForCrown} (lenient hearingId+courtCentreId matchers so - * it matches regardless of the request's date/duration/start-time params) but reports the booked - * session as {@code isDraft:true}. The update path ({@code handleCrownUpdateSearchAndBook}) takes - * only {@code courtScheduleId}+{@code isDraft} from this response and lets the aggregate unallocate - * β€” clearing the previously-allocated court room. A courtRoomId is still emitted purely to satisfy - * the parser ({@code searchAndBookSlots} reads it unconditionally); it is discarded downstream. + * Stub POST /hearings/{hearingId} (mags.search.and.book) for CROWN draft scenario + * (was GET /searchlist/hearingslots). Reports booked session as {@code "draft":true} in sessions[0]. + * The update path ({@code handleCrownUpdateSearchAndBook}) takes only courtScheduleId+draft from + * this response and lets the aggregate unallocate β€” clearing the previously-allocated court room. */ public static void stubSearchBookHearingSlotsForCrownDraft(final String hearingId, final String courtCentreId) { + final String courtScheduleId = UUID.randomUUID().toString(); + final String sessionStartTime = ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0).toString(); final String payload = "{\n" + - " \"hearingSlots\": {\n" + - " \"hearingId\": \"" + hearingId + "\",\n" + - " \"courtScheduleId\": \"" + UUID.randomUUID() + "\",\n" + + " \"hearingId\": \"" + hearingId + "\",\n" + + " \"sessions\": [\n" + + " {\n" + + " \"courtScheduleId\": \"" + courtScheduleId + "\",\n" + " \"courtRoomId\": \"" + courtCentreId + "\",\n" + - " \"hearingDate\": \"" + ItClock.today().plusDays(5) + "\",\n" + - " \"hearingStartTime\": \"" + ItClock.nowUtc().plusDays(5).withHour(10).withMinute(0).withSecond(0).withNano(0) + "\",\n" + - " \"duration\": 30,\n" + - " \"isDraft\": true\n" + - " }\n" + + " \"sessionStartTime\": \"" + sessionStartTime + "\",\n" + + " \"draft\": true,\n" + + " \"businessType\": \"CR\",\n" + + " \"judiciaries\": []\n" + + " }\n" + + " ]\n" + "}"; - stubFor(get(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) - .withQueryParam("hearingId", matching(hearingId)) - .withQueryParam("courtCentreId", matching(courtCentreId)) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(payload) .withHeader(CONTENT_TYPE, APPLICATION_JSON) @@ -1335,42 +1381,44 @@ public static void stubSearchBookHearingSlotsForCrownDraft(final String hearingI * take precedence over these (priority 10). */ public static void stubCourtSchedulerCatchAll() { - // GET /searchlist/hearingslots β€” searchBookSlots - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/searchlist/hearingslots"))) + // POST /hearings/{id} β€” mags.search.and.book (was GET /searchlist/hearingslots) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(MAGS_SEARCH_AND_BOOK_TYPE)) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":{}}") + .withBody("{\"hearingId\":\"\",\"sessions\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /hearingslots β€” search (available hearing slots) + // GET /hearingslots β€” search (available hearing slots) β€” NO CHANGE stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody("{\"hearingSlots\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /courtschedule/search.court-schedules-by-id - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/courtschedule/search.court-schedules-by-id"))) + // GET /sessions β€” search-court-schedules-by-id (was /courtschedule/search.court-schedules-by-id) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":[]}") + .withBody("{\"courtSchedules\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // GET /multidaysearchandbook/hearingslots - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/multidaysearchandbook/hearingslots"))) + // POST /hearings/{id} β€” crown.search.and.book (was GET /multidaysearchandbook/hearingslots and GET /crownfallbacksearchandbook/hearingslots) + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH + "/[0-9a-fA-F-]+"))) + .withHeader(CONTENT_TYPE, containing(CROWN_SEARCH_AND_BOOK_TYPE)) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withBody("{\"hearingSlots\":{}}") + .withBody("{\"sessions\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // PUT /list/hearingslots β€” listHearingInCourtSessions - stubFor(WireMock.put(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + "/list/hearingslots"))) + // POST /hearings β€” listHearingInCourtSessions (was PUT /list/hearingslots) + stubFor(WireMock.post(WireMock.urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + HEARINGS_PATH))) .atPriority(10) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody("{\"hearings\":[]}") .withHeader(CONTENT_TYPE, APPLICATION_JSON))); - // POST /hearingslots β€” updateAvailableHearingSlots + // POST /hearingslots β€” updateAvailableHearingSlots β€” NO CHANGE stubFor(WireMock.post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + HEARING_SLOTS))) .atPriority(10) .willReturn(aResponse().withStatus(NO_CONTENT.getStatusCode()))); @@ -1431,12 +1479,76 @@ public static void stubGetCourtSchedulesByIdWithDraftStatus(final List c } private static void stubCourtSchedulesByIdResponse(final String body) { - stubFor(get(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/courtschedule/search.court-schedules-by-id"))) - .withQueryParam("courtScheduleIds", matching(".*")) + // Endpoint reshaped: was GET /courtschedule/search.court-schedules-by-id?courtScheduleIds=... + // Now GET /sessions?ids=... (query param key changed from courtScheduleIds to ids) + stubFor(get(urlPathEqualTo(format("%s", COURT_SCHEDULER_ENDPOINT + SESSIONS_PATH))) + .withQueryParam("ids", matching(".*")) .withHeader("Accept", containing("application/vnd.courtscheduler.search.court-schedules-by-id+json")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + + // --- move-hearing-to-past-date stubs (MAGISTRATES-only: CROWN never reaches courtscheduler, Baris decision D1) --- + + /** Stub a successful POST /hearings/{hearingId} move-hearing-to-past-date response. */ + public static void stubMoveHearingToPastDate(final String hearingId, + final String courtScheduleId, + final String courtRoomId, + final LocalDate sessionDate, + final int durationInMinutes) { + final String startTime = sessionDate + "T09:00:00Z"; + final String endTime = sessionDate + "T17:00:00Z"; + final String body = format( + "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," + + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + + "\"durationInMinutes\":%s}", + hearingId, courtScheduleId, courtRoomId, sessionDate, startTime, endTime, durationInMinutes); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withBody(body) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Stub a courtscheduler rejection (e.g. 422 FUTURE_DATE_NOT_ALLOWED / NO_SESSION_FOUND, or a + * legacy 404 no-session) for move-hearing-to-past-date. */ + public static void stubMoveHearingToPastDateFailure(final String hearingId, + final int statusCode, + final String errorCode, + final String message) { + final StringBuilder body = new StringBuilder("{"); + if (errorCode != null) { + body.append("\"errorCode\":\"").append(errorCode).append("\","); + } + body.append("\"message\":\"").append(message).append("\"}"); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .willReturn(aResponse().withStatus(statusCode) + .withBody(body.toString()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing + * (matched on the URL path - hearingId no longer travels in the request body). */ + public static void verifyMoveHearingToPastDateCalled(final String hearingId) { + Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { + try { + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); + return true; + } catch (VerificationException e) { + return false; + } + }); + } + + /** Regression guard for the CROWN listing-side-only path: courtscheduler must never be called. */ + public static void verifyMoveHearingToPastDateNeverCalled(final String hearingId) { + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); + } } diff --git a/listing-integration-test/src/test/resources/endpoint.properties b/listing-integration-test/src/test/resources/endpoint.properties index fbe0fde74..fbfd6a9b9 100644 --- a/listing-integration-test/src/test/resources/endpoint.properties +++ b/listing-integration-test/src/test/resources/endpoint.properties @@ -46,6 +46,7 @@ listing.publishedcourtlist=listing-service/query/view/rest/listing/publishedcour listing.command.publish-court-lists-for-crown-courts=listing-service/command/api/rest/listing/publishCourtListsForCrownCourts listing.command.extend-hearing-for-hearing=listing-service/command/api/rest/listing/hearings/{0} listing.command.hearing-vacate-trial=listing-service/command/api/rest/listing/hearings/{0} +listing.command.move-hearing-to-past-date=listing-service/command/api/rest/listing/hearings/{0} listing.command.create-listing-note=listing-service/command/api/rest/listing/listing-note listing.command.edit-listing-note=listing-service/command/api/rest/listing/listing-notes/{0} listing.search.hearings.by.allocated.court-room-id.search-date=listing-service/query/api/rest/listing/hearings/?allocated={0}&courtRoomId={1}&searchDate={2} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json new file mode 100644 index 000000000..ab58d1d4f --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,4 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json new file mode 100644 index 000000000..ebe98fdc5 --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-reschedule.json @@ -0,0 +1,32 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "courtRoomId": "%%COURT_ROOM_ID%%", + "selectedCourtCentre": { + "id": "%%COURT_CENTRE_ID%%", + "courtRoomId": "%%COURT_ROOM_ID%%", + "courtCentreName": "Test Court Centre", + "ouCode": "B01LY00" + }, + "type": { + "id": "4a0e892d-c0c5-3c51-95b8-704d8c781776", + "description": "Plea" + }, + "startDate": "%%TARGET_DATE%%", + "endDate": "%%TARGET_DATE%%", + "jurisdictionType": "CROWN", + "hearingLanguage": "ENGLISH", + "publicListNote": "", + "hasVideoLink": false, + "sendNotificationToParties": false, + "nonSittingDays": [], + "nonDefaultDays": [ + { + "startTime": "%%TARGET_DATE%%T09:00:00.000Z", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "roomId": "%%COURT_ROOM_ID%%", + "duration": 60, + "courtScheduleId": "%%NEW_COURT_SCHEDULE_ID%%" + } + ], + "judiciary": [] +} diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json new file mode 100644 index 000000000..02abdeb16 --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/update-hearing-for-listing/update-hearing-for-listing-crown-singleday-schedule-only.json @@ -0,0 +1,24 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "type": { + "id": "4a0e892d-c0c5-3c51-95b8-704d8c781776", + "description": "Plea" + }, + "startDate": "%%TARGET_DATE%%", + "endDate": "%%TARGET_DATE%%", + "jurisdictionType": "CROWN", + "hearingLanguage": "ENGLISH", + "publicListNote": "", + "hasVideoLink": false, + "sendNotificationToParties": false, + "nonSittingDays": [], + "nonDefaultDays": [ + { + "startTime": "%%TARGET_DATE%%T09:00:00.000Z", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "duration": 20, + "courtScheduleId": "%%COURT_SCHEDULE_ID%%" + } + ], + "judiciary": [] +} diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json new file mode 100644 index 000000000..ab58d1d4f --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,4 @@ +{ + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +}