diff --git a/listing-command/listing-command-api/pom.xml b/listing-command/listing-command-api/pom.xml index 272c0dc0a..fe1a6bb85 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 5ca66cedd..ae219effd 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"; @@ -101,6 +127,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) { @@ -329,6 +359,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/mapper/ListingCommandCommonProviders.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java new file mode 100644 index 000000000..eeb477f6d --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java @@ -0,0 +1,33 @@ +package uk.gov.moj.cpp.listing.command.api.mapper; + +import uk.gov.justice.services.adapter.rest.application.DefaultCommonProviders; + +import java.util.Set; + +import javax.enterprise.inject.Specializes; + +/** + * Registers the listing command-api's custom JAX-RS providers with the generated REST + * {@code Application}. The framework-generated Application injects {@code CommonProviders} and + * builds its {@code getClasses()} set solely from {@link #providers()} — providers are NOT + * classpath-scanned, so a {@code @Provider} annotation alone does nothing. {@code @Specializes} + * makes this bean replace the framework's {@code @Default DefaultCommonProviders} at that + * injection point while inheriting its qualifiers. + * + *

This is the single specialization point for command-api JAX-RS providers. + * CDI allows only ONE bean to specialize {@link DefaultCommonProviders} per deployment — adding a + * second {@code @Specializes DefaultCommonProviders} subclass anywhere on the classpath makes the + * whole WAR fail to deploy (inconsistent specialization). Register any future command-api + * provider (exception mappers, filters, features) by adding its class here; do NOT create another + * specializing subclass. + */ +@Specializes +public class ListingCommandCommonProviders extends DefaultCommonProviders { + + @Override + public Set> providers() { + final Set> providers = super.providers(); + providers.add(MoveHearingToPastDateExceptionMapper.class); + return providers; + } +} diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java new file mode 100644 index 000000000..ecbe10515 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java @@ -0,0 +1,46 @@ +package uk.gov.moj.cpp.listing.command.api.mapper; + +import static javax.json.Json.createObjectBuilder; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.Response.status; +import static uk.gov.justice.services.messaging.JsonObjects.getString; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; + +import javax.inject.Inject; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import org.slf4j.Logger; + +@Provider +public class MoveHearingToPastDateExceptionMapper implements ExceptionMapper { + + @Inject + Logger logger; + + @Override + public Response toResponse(final MoveHearingToPastDateException exception) { + logger.debug("move-hearing-to-past-date rejected", exception); + + final JsonObjectBuilder builder = createObjectBuilder(); + if (exception.getErrorCode() != null) { + builder.add("errorCode", exception.getErrorCode()); + } + final JsonObject responseBody = exception.getResponseBody(); + final String message = responseBody == null + ? exception.getMessage() + : getString(responseBody, "message").orElse(exception.getMessage()); + if (message != null) { + builder.add("message", message); + } + + return status(exception.getHttpStatus()) + .entity(builder.build().toString()) + .type(APPLICATION_JSON) + .build(); + } +} diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java new file mode 100644 index 000000000..b3420ca66 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java @@ -0,0 +1,60 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static uk.gov.justice.services.core.annotation.Component.QUERY_API; +import static uk.gov.justice.services.messaging.JsonEnvelope.metadataFrom; +import static uk.gov.justice.services.messaging.JsonObjects.createObjectBuilder; + +import uk.gov.justice.services.core.annotation.ServiceComponent; +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.messaging.JsonEnvelope; +import uk.gov.justice.services.messaging.Metadata; +import uk.gov.moj.cpp.listing.query.view.HearingQueryView; + +import java.util.Optional; +import java.util.UUID; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.json.JsonObject; +import javax.ws.rs.NotFoundException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Synchronous, in-process lookup of a hearing from the listing viewstore, for command-api-side + * existence/validation checks that must happen before a command is sent (e.g. + * move-hearing-to-past-date's unknown-hearingId 422). Mirrors the + * {@code uk.gov.moj.cpp.listing.command.service.HearingService} pattern already used from + * listing-command-handler, moved into command-api since the check has to happen here to be able + * to reject with a synchronous 422 before anything is sent. + */ +@ApplicationScoped +public class HearingLookupService { + + private static final Logger LOGGER = LoggerFactory.getLogger(HearingLookupService.class); + private static final String HEARING_QUERY_BY_HEARING_ID = "listing.search.hearing"; + + @Inject + private Enveloper enveloper; + + @Inject + @ServiceComponent(QUERY_API) + private HearingQueryView hearingQueryView; + + public Optional findHearing(final UUID hearingId, final JsonEnvelope envelope) { + final JsonObject payload = createObjectBuilder().add("id", hearingId.toString()).build(); + + final Metadata metadata = metadataFrom(envelope.metadata()).withName(HEARING_QUERY_BY_HEARING_ID).build(); + final JsonEnvelope query = JsonEnvelope.envelopeFrom(metadata, payload); + final JsonEnvelope request = enveloper.withMetadataFrom(query, HEARING_QUERY_BY_HEARING_ID).apply(payload); + + try { + final JsonEnvelope response = hearingQueryView.getHearingById(request); + return Optional.of(response.payloadAsJsonObject()); + } catch (final NotFoundException e) { + LOGGER.debug("No hearing found for hearingId {}", hearingId, e); + return Optional.empty(); + } + } +} diff --git a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl index f87b40a53..1993fe74a 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 @@ -239,3 +239,12 @@ rule "Command - API - Action - listing.update-hearing-add-case-bdf" $outcome.setSuccess(true); end +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.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 f0d378ebe..57a22865f 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 9f8b3351e..45d681416 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 @@ -46,6 +46,7 @@ public class ListingAccessControlTest extends BaseDroolsAccessControlTest { private static final String ACTION_MARK_UNALLOCATED_HEARING_AS_DUPLICATE = "listing.mark-unallocated-hearing-as-duplicate"; private static final String ACTION_DELETE_HEARING = "listing.command.delete-hearing"; private static final String ACTION_DELETE_PREVIOUS_HEARINGS_AND_CREATE_NEXT_HEARING = "listing.delete-previous-hearings-and-create-next-hearing"; + private static final String ACTION_MOVE_HEARING_TO_PAST_DATE = "listing.command.move-hearing-to-past-date"; @@ -307,4 +308,25 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() { assertFailureOutcome(results); } + + @Test + public void shouldAllowAuthorisedUserToMoveHearingToPastDate() { + final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); + given(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups(action, LISTING_OFFICERS, + CROWN_COURT_ADMIN, COURT_ADMINISTRATORS, COURT_CLERKS, LEGAL_ADVISERS, COURT_ASSOCIATE)) + .willReturn(true); + + final ExecutionResults results = executeRulesWith(action); + + assertSuccessfulOutcome(results); + } + + @Test + 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 cd85ad3df..5308dbee2 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() { diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java new file mode 100644 index 000000000..cf37f25ea --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java @@ -0,0 +1,74 @@ +package uk.gov.moj.cpp.listing.command.api.mapper; + +import static javax.json.Json.createObjectBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; + +import javax.json.JsonObject; +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class MoveHearingToPastDateExceptionMapperTest { + + private MoveHearingToPastDateExceptionMapper mapper; + + @BeforeEach + void setUp() { + mapper = new MoveHearingToPastDateExceptionMapper(); + mapper.logger = LoggerFactory.getLogger(MoveHearingToPastDateExceptionMapperTest.class); + } + + @Test + void futureDate_returns422_withErrorCodeAndMessage() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "Hearings can only be moved to today or an earlier date") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected")); + + assertThat(response.getStatus(), is(422)); + final String entity = response.getEntity().toString(); + assertThat(entity, containsString("\"errorCode\":\"FUTURE_DATE_NOT_ALLOWED\"")); + assertThat(entity, containsString("\"message\":\"Hearings can only be moved to today or an earlier date\"")); + } + + @Test + void unknownHearing_returns422_withHearingIdNotFound() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "HEARING_ID_NOT_FOUND") + .add("message", "No hearing found") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected")); + + assertThat(response.getStatus(), is(422)); + assertThat(response.getEntity().toString(), containsString("\"errorCode\":\"HEARING_ID_NOT_FOUND\"")); + } + + @Test + void noSession_propagates404_withMessage() { + final JsonObject body = createObjectBuilder() + .add("message", "No court-schedule session found") + .build(); + + final Response response = mapper.toResponse(new MoveHearingToPastDateException(404, body, "not found")); + + assertThat(response.getStatus(), is(404)); + assertThat(response.getEntity().toString(), containsString("\"message\":\"No court-schedule session found\"")); + } + + @Test + void nullBody_fallsBackToExceptionMessage() { + final Response response = mapper.toResponse(new MoveHearingToPastDateException(500, null, "unexpected failure")); + + assertThat(response.getStatus(), is(500)); + assertThat(response.getEntity().toString(), containsString("\"message\":\"unexpected failure\"")); + } +} diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java new file mode 100644 index 000000000..b540ac3f4 --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java @@ -0,0 +1,75 @@ +package uk.gov.moj.cpp.listing.command.api.service; + +import static java.util.UUID.randomUUID; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static uk.gov.justice.services.test.utils.core.enveloper.EnveloperFactory.createEnveloper; +import static uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory.metadataWithRandomUUIDAndName; + +import uk.gov.justice.services.core.enveloper.Enveloper; +import uk.gov.justice.services.messaging.JsonEnvelope; +import uk.gov.moj.cpp.listing.query.view.HearingQueryView; + +import java.util.Optional; +import java.util.UUID; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.ws.rs.NotFoundException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HearingLookupServiceTest { + + @Spy + private final Enveloper enveloper = createEnveloper(); + + @Mock + private HearingQueryView hearingQueryView; + + @Mock + private JsonEnvelope envelope; + + @InjectMocks + private HearingLookupService hearingLookupService; + + @Test + void shouldReturnHearingWhenFound() { + final UUID hearingId = randomUUID(); + final JsonObject hearingPayload = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenReturn(JsonEnvelope.envelopeFrom(metadataWithRandomUUIDAndName().build(), hearingPayload)); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isPresent()); + assertThat(result.get().getString("jurisdictionType"), is("MAGISTRATES")); + } + + @Test + void shouldReturnEmptyWhenHearingNotFound() { + final UUID hearingId = randomUUID(); + + when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build()); + when(hearingQueryView.getHearingById(any(JsonEnvelope.class))) + .thenThrow(new NotFoundException("no hearing")); + + final Optional result = hearingLookupService.findHearing(hearingId, envelope); + + assertTrue(result.isEmpty()); + } +} diff --git a/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json new file mode 100644 index 000000000..2f33b700e --- /dev/null +++ b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json @@ -0,0 +1,6 @@ +{ +"object":"Change hearing to past date", +"action":"Link", +"key":"Change hearing to past date_Link", +"keyWithOutSource":"Change hearing to past date_Link" +} diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index a9ecb14dd..d517b4cab 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 @@ -189,6 +189,16 @@ public class ListingCommandHandler { private static final String HEARING_DAY_COURT_SCHEDULES = "hearingDayCourtSchedules"; 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; @@ -406,6 +416,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 cb7060834..6ef00e9a4 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 3b377d10a..9f09a160b 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 b493a0dab..6c82e3661 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 @@ -5,6 +5,8 @@ import uk.gov.justice.services.common.converter.JsonObjectToObjectConverter; import uk.gov.justice.services.common.converter.ObjectToJsonObjectConverter; +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; @@ -21,8 +23,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; @@ -47,6 +51,14 @@ public class CourtSchedulerServiceAdapter { public static final String PANEL_ADULT_YOUTH = "ADULT,YOUTH"; private static final String PANEL = "panel"; public static final String HEARING_ID = "hearingId"; + public static final String COURT_CENTRE_ID = "courtCentreId"; + public static final String JURISDICTION = "jurisdiction"; + public static final String START_DATE = "startDate"; + public static final String DURATION_IN_MINUTES = "durationInMinutes"; + public static final String MAGISTRATES_JURISDICTION = "MAGISTRATES"; + 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 @@ -220,4 +232,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("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, + body.getString(COURT_ROOM_ID, null), + body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, + body.getString("sessionStartTime", null), + body.getString("sessionEndTime", null), + body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); + } } 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 137cf7c49..4a1d1b3be 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 @@ -26,6 +26,7 @@ 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.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; @@ -35,6 +36,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.json.JsonObject; + @SuppressWarnings({"squid:S1312", "squid:S2629", "squid:S6813"}) @ApplicationScoped public class HearingSlotsService { @@ -53,6 +56,9 @@ public class HearingSlotsService { 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 HEARINGS_RESOURCE = "/hearings/"; + private static final String COURTSCHEDULER_MOVE_TO_PAST_DATE = "application/vnd.courtscheduler.move-hearing-to-past-date+json"; + private static final String CJS_CPP_UID = "CJSCPPUID"; @Inject @Value(key = "courtscheduler.base.url", defaultValue = "http://localhost:8080/listingcourtscheduler-api/rest/courtscheduler") @@ -117,6 +123,40 @@ public Response getCourtSchedulesById(final Map params) { return query(COURTSCHEDULES_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params); } + public Response moveHearingToPastDate(final UUID hearingId, final JsonObject payload) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date for hearing id '{}'", hearingId); + } + + try { + final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + hearingId).toString()); + httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_MOVE_TO_PAST_DATE); + httpPost.addHeader(CJS_CPP_UID, getUserId().toString()); + + final StringEntity requestEntity = new StringEntity(payload.toString()); + httpPost.setEntity(requestEntity); + + final HttpResponse httpResponse = execute(httpPost); + final int statusCode = httpResponse.getStatusLine().getStatusCode(); + final String entityBodyAsString = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity()); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("move-hearing-to-past-date returned status {}", statusCode); + } + + return Response + .status(statusCode) + .entity(entityBodyAsString.isBlank() ? null : stringToJsonObjectConverter.convert(entityBodyAsString)) + .build(); + } catch (IOException ex) { + LOGGER.error("Exception thrown on trying to move hearing to past date", ex); + return Response + .status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .entity(ex.getMessage()) + .build(); + } + } + public void delete(final UUID hearingId) { if (LOGGER.isInfoEnabled()) { LOGGER.info("Delete HearingSlots in CourtScheduler S & L with hearing id '{}'", hearingId); diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java new file mode 100644 index 000000000..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 b38cebb37..7b7c3098d 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 @@ -23,6 +23,7 @@ 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.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; @@ -65,6 +66,8 @@ class HearingSlotsServiceTest { private ArgumentCaptor httpPutCaptor; @Captor private ArgumentCaptor httpDeleteCaptor; + @Captor + private ArgumentCaptor httpPostCaptor; @InjectMocks private HearingSlotsService hearingSlotsService; @@ -425,4 +428,78 @@ public void shouldThrowExceptionWhenSearchAndBookParamsAreNull() { assertThat(e.getMessage(), is("Params for search application/vnd.courtscheduler.search.book.hearing.slots+json is null ....")); } } + + @Test + public void shouldPostMoveHearingToPastDateSuccessfully() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(httpClient).execute(httpPostCaptor.capture()); + final HttpPost capturedPost = httpPostCaptor.getValue(); + assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID)); + assertThat(capturedPost.getFirstHeader("Content-Type").getValue(), + is("application/vnd.courtscheduler.move-hearing-to-past-date+json")); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateErrorResponse() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(422); + when(httpResponse.getEntity()).thenReturn(null); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(422)); + } + } + + @Test + public void shouldHandleMoveHearingToPastDateIOException() throws Exception { + // Given + when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID)); + final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder() + .add("hearingId", TEST_HEARING_ID.toString()) + .build(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) { + mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder); + when(httpClientBuilder.build()).thenReturn(httpClient); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Test exception")); + + // When + final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload); + + // Then + assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())); + } + } } \ No newline at end of file diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java new file mode 100644 index 000000000..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/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 bfca77eb2..d2b51a0a3 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 @@ -57,6 +57,7 @@ public class CourtSchedulerServiceStub { 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 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"; @@ -1436,4 +1437,66 @@ private static void stubCourtSchedulesByIdResponse(final String body) { .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + + // --- move-hearing-to-past-date stubs (MAGISTRATES-only: CROWN never reaches courtscheduler, Baris decision D1) --- + + /** Stub a successful POST /hearings/{hearingId} move-hearing-to-past-date response. */ + public static void stubMoveHearingToPastDate(final String hearingId, + final String courtScheduleId, + final String courtRoomId, + final LocalDate sessionDate, + final int durationInMinutes) { + final String startTime = sessionDate + "T09:00:00Z"; + final String endTime = sessionDate + "T17:00:00Z"; + final String body = format( + "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," + + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + + "\"durationInMinutes\":%s}", + hearingId, courtScheduleId, courtRoomId, sessionDate, startTime, endTime, durationInMinutes); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .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 8d9ef3764..f462e3dc0 100644 --- a/listing-integration-test/src/test/resources/endpoint.properties +++ b/listing-integration-test/src/test/resources/endpoint.properties @@ -45,6 +45,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/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%%" +}