From 7edbb8170f7e83191b9b8376a2a9e77d76617b69 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:09:28 +0100 Subject: [PATCH 01/10] feat(SPRDT-987): move-hearing-to-past-date wrapper (MAGS via courtscheduler, CROWN listing-side, past-only) Adds listing.command.move-hearing-to-past-date as a new content-type action on the existing POST /hearings/{hearingId} resource. ListingCommandApi resolves the hearing from the listing viewstore (new HearingLookupService, mirroring the existing command.service.HearingService query-view lookup pattern) and rejects unknown hearingIds with 422 HEARING_ID_NOT_FOUND before anything is sent. MAGISTRATES hearings call courtscheduler synchronously via CourtSchedulerServiceAdapter.moveHearingToPastDate (new HearingSlotsService POST to /hearings/{hearingId}, application/vnd.courtscheduler.move-hearing-to-past-date+json); a 2xx response enriches the command with a purpose-built MoveHearingToPastDateResult and the enriched handler applies it via the existing raiseHearingDayCourtSchedulesUpdated/HearingDayCourtScheduleUpdated event. A non-2xx response raises MoveHearingToPastDateException, rendered as 422/404 by the new MoveHearingToPastDateExceptionMapper (registered via the new ListingCommandCommonProviders @Specializes DefaultCommonProviders) - no event is sent. CROWN hearings never call courtscheduler (Baris decision D1): the command-api validates past-only (422 FUTURE_DATE_NOT_ALLOWED) and the enriched handler re-dates the hearing purely listing-side by reusing the existing Hearing.changeStartDate aggregate method (StartDateChangedForHearing event) - no new domain event needed. Permission: PermissionConstants.createChangeHearingToPastDatePermission() (object "Change hearing to past date", action "Link") plus a DRL rule using userAndGroupProvider.hasPermission(...). Adds MoveHearingToPastDateIT plus CourtSchedulerServiceStub/WireMockStubUtils extensions and MAGS/CROWN test-data fixtures. --- listing-command/listing-command-api/pom.xml | 5 + .../command/api/ListingCommandApi.java | 95 +++++++++ .../accesscontrol/PermissionConstants.java | 32 ++++ .../mapper/ListingCommandCommonProviders.java | 33 ++++ .../MoveHearingToPastDateExceptionMapper.java | 46 +++++ .../api/service/HearingLookupService.java | 60 ++++++ .../api/accesscontrol/listing-command-api.drl | 10 + ...ing.command.move-hearing-to-past-date.json | 5 + ...ing.command.move-hearing-to-past-date.json | 23 +++ .../src/raml/listing-command-api.raml | 6 + .../command/api/ListingAccessControlTest.java | 22 +++ .../command/api/ListingCommandApiTest.java | 181 ++++++++++++++++++ .../PermissionConstantsTest.java | 23 +++ ...eHearingToPastDateExceptionMapperTest.java | 74 +++++++ .../api/service/HearingLookupServiceTest.java | 75 ++++++++ ...hange-hearing-to-past-date-permission.json | 6 + .../handler/ListingCommandHandler.java | 30 +++ ...nd.move-hearing-to-past-date-enriched.json | 11 ++ ...nd.move-hearing-to-past-date-enriched.json | 43 +++++ .../listing-command-handler.messaging.raml | 4 + .../handler/ListingCommandHandlerTest.java | 48 +++++ .../MoveHearingToPastDateException.java | 38 ++++ .../pastdate/MoveHearingToPastDateResult.java | 18 ++ .../service/CourtSchedulerServiceAdapter.java | 54 ++++++ .../common/service/HearingSlotsService.java | 40 ++++ ...rviceAdapterMoveHearingToPastDateTest.java | 118 ++++++++++++ .../service/HearingSlotsServiceTest.java | 77 ++++++++ .../listing/it/MoveHearingToPastDateIT.java | 145 ++++++++++++++ .../steps/MoveHearingToPastDateSteps.java | 111 +++++++++++ .../utils/CourtSchedulerServiceStub.java | 64 +++++++ .../cpp/listing/utils/WireMockStubUtils.java | 22 +++ .../src/test/resources/endpoint.properties | 1 + .../move-hearing-to-past-date.json | 5 + .../move-hearing-to-past-date.json | 5 + 34 files changed, 1530 insertions(+) create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java create mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java create mode 100644 listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json create mode 100644 listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java create mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java create mode 100644 listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json create mode 100644 listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json create mode 100644 listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json create mode 100644 listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java create mode 100644 listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java create mode 100644 listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java create mode 100644 listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java create mode 100644 listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json create mode 100644 listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json diff --git a/listing-command/listing-command-api/pom.xml b/listing-command/listing-command-api/pom.xml index f2f40e5a0..5136ba693 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..5cd377137 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -39,9 +39,14 @@ import uk.gov.justice.services.messaging.JsonEnvelope; import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory; import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator; +import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; +import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter; import uk.gov.moj.cpp.listing.common.service.HearingSlotsService; import uk.gov.moj.cpp.listing.domain.VacateTrialEnriched; +import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -71,6 +76,23 @@ public class ListingCommandApi { private static final String LISTING_COMMAND_LIST_UNSCHEDULED_NEXT_HEARINGS_ENRICHED = "listing.command.list-unscheduled-next-hearings-enriched"; private static final String LISTING_COMMAND_EXTEND_HEARING_FOR_HEARING_ENRICHED = "listing.command.extend-hearing-for-hearing-enriched"; private static final String LISTING_COMMAND_VACATE_TRIAL = "listing.command.vacate-trial-enriched"; + private static final String LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED = "listing.command.move-hearing-to-past-date-enriched"; + private static final String COURT_CENTRE_ID = "courtCentreId"; + private static final String START_DATE = "startDate"; + private static final String JURISDICTION = "jurisdiction"; + private static final String JURISDICTION_TYPE = "jurisdictionType"; + private static final String ESTIMATED_MINUTES = "estimatedMinutes"; + private static final String COURT_SCHEDULE_ID = "courtScheduleId"; + private static final String COURT_ROOM_ID = "courtRoomId"; + private static final String SESSION_DATE = "sessionDate"; + private static final String SESSION_START_TIME = "sessionStartTime"; + private static final String SESSION_END_TIME = "sessionEndTime"; + private static final String DURATION_IN_MINUTES = "durationInMinutes"; + private static final String ERROR_CODE = "errorCode"; + private static final String MESSAGE = "message"; + public static final String HEARING_ID_NOT_FOUND = "HEARING_ID_NOT_FOUND"; + public static final String FUTURE_DATE_NOT_ALLOWED = "FUTURE_DATE_NOT_ALLOWED"; + private static final String CROWN_JURISDICTION = "CROWN"; private static final String LISTING_COMMAND_CORRECT_HEARING_DAYS_WO_CC = "listing.command.correct-hearing-days-without-court-centre"; private static final String LISTING_COMMAND_DUPLICATE_UNALLOCATED_HEARING = "listing.command.mark-unallocated-hearing-as-duplicate"; private static final String LISTING_COMMAND_UPDATE_EXISTING_HEARING = "listing.command.update-existing-hearing"; @@ -101,6 +123,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 +355,75 @@ public void handleVacateTrial(final JsonEnvelope envelope) { envelope.payload())); } + @Handles("listing.command.move-hearing-to-past-date") + public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { + final JsonObject payload = envelope.payloadAsJsonObject(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("'listing.command.move-hearing-to-past-date' received with payload {}", envelope.toObfuscatedDebugString()); + } + + final UUID hearingId = fromString(payload.getString(HEARING_ID)); + final UUID courtCentreId = fromString(payload.getString(COURT_CENTRE_ID)); + final LocalDate startDate = LocalDate.parse(payload.getString(START_DATE)); + + final JsonObject hearing = hearingLookupService.findHearing(hearingId, envelope) + .orElseThrow(() -> new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(HEARING_ID_NOT_FOUND, "No hearing found for hearingId " + hearingId), + "No hearing found for hearingId " + hearingId)); + + final String jurisdictionType = hearing.getString(JURISDICTION_TYPE, null); + + final JsonObjectBuilder enrichedBuilder = createObjectBuilder() + .add(HEARING_ID, hearingId.toString()) + .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType) + .add(START_DATE, startDate.toString()); + + if (CROWN_JURISDICTION.equals(jurisdictionType)) { + // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. + if (startDate.isAfter(LocalDate.now())) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), + "Hearings can only be moved to today or an earlier date"); + } + } else { + final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) + ? hearing.getInt(ESTIMATED_MINUTES) : null; + + final MoveHearingToPastDateResult slot = + courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); + + if (slot.courtScheduleId() != null) { + enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); + } + if (slot.courtRoomId() != null) { + enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); + } + if (slot.sessionDate() != null) { + enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); + } + if (slot.sessionStartTime() != null) { + enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); + } + if (slot.sessionEndTime() != null) { + enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); + } + if (slot.durationInMinutes() != null) { + enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); + } + } + + sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED), + enrichedBuilder.build())); + } + + private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) { + return createObjectBuilder() + .add(ERROR_CODE, errorCode) + .add(MESSAGE, message) + .build(); + } + @Handles("listing.command.extend-hearing-for-hearing") public void handleExtendHearingForHearing(final JsonEnvelope envelope) { diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java new file mode 100644 index 000000000..97bb9ea37 --- /dev/null +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java @@ -0,0 +1,32 @@ +package uk.gov.moj.cpp.listing.command.api.accesscontrol; + +import static uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission.builder; + +import uk.gov.justice.services.common.converter.jackson.ObjectMapperProducer; +import uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@JsonPropertyOrder({"object", "action", "key", "keyWithOutSource"}) +public final class PermissionConstants { + + private static final ObjectMapper objectMapper = new ObjectMapperProducer().objectMapper(); + + private static final String CHANGE_HEARING_TO_PAST_DATE_OBJECT = "Change hearing to past date"; + private static final String LINK_ACTION = "Link"; + + private PermissionConstants() { + } + + public static String createChangeHearingToPastDatePermission() throws JsonProcessingException { + final ExpectedPermission expectedPermission = builder() + .withObject(CHANGE_HEARING_TO_PAST_DATE_OBJECT) + .withAction(LINK_ACTION) + .build(); + + return objectMapper.writeValueAsString(expectedPermission); + } + +} diff --git a/listing-command/listing-command-api/src/main/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..454408627 100644 --- a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl +++ b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl @@ -3,6 +3,7 @@ package uk.gov.moj.cpp.listing.command.api.accesscontrol; import uk.gov.moj.cpp.accesscontrol.drools.Outcome; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.listing.domain.RuleConstants; +import uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants; import java.util.Arrays; global uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider userAndGroupProvider; @@ -239,3 +240,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.hasPermission($action, PermissionConstants.createChangeHearingToPastDatePermission())); + then + $outcome.setSuccess(true); +end + diff --git a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json new file mode 100644 index 000000000..1f6e0d5ff --- /dev/null +++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "0baecac5-222b-402d-9047-84803679edae", + "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", + "startDate": "2026-05-01" +} diff --git a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json new file mode 100644 index 000000000..d77eddfc5 --- /dev/null +++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date.json", + "type": "object", + "properties": { + "hearingId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "startDate": { + "type": "string", + "format": "date" + } + }, + "required": [ + "hearingId", + "courtCentreId", + "startDate" + ], + "additionalProperties": false +} diff --git a/listing-command/listing-command-api/src/raml/listing-command-api.raml b/listing-command/listing-command-api/src/raml/listing-command-api.raml index 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..9ae1c6fa4 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java @@ -2,6 +2,7 @@ import static java.util.Collections.singletonMap; import static org.mockito.BDDMockito.given; +import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ADMINISTRATORS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ASSOCIATE; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_CLERKS; @@ -17,6 +18,7 @@ import static uk.gov.moj.cpp.listing.domain.RuleConstants.SYSTEM_USERS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.YOTS; +import com.fasterxml.jackson.core.JsonProcessingException; import uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.accesscontrol.test.utils.BaseDroolsAccessControlTest; @@ -46,6 +48,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 +310,23 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() { assertFailureOutcome(results); } + + @Test + public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToPastDate() throws JsonProcessingException { + final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); + given(userAndGroupProvider.hasPermission(action, createChangeHearingToPastDatePermission())).willReturn(true); + + final ExecutionResults results = executeRulesWith(action); + + assertSuccessfulOutcome(results); + } + + @Test + public void shouldNotAllowUserWithoutChangeHearingToPastDatePermissionToMoveHearingToPastDate() { + final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); + + final ExecutionResults results = executeRulesWith(action); + + assertFailureOutcome(results); + } } \ No newline at end of file diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index cd85ad3df..d1b4cc187 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -68,6 +68,10 @@ import uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory; import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory; import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator; +import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; +import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter; import uk.gov.moj.cpp.listing.common.service.HearingSlotsService; import uk.gov.moj.cpp.listing.domain.JudicialRole; import uk.gov.moj.cpp.listing.domain.JudicialRoleType; @@ -80,10 +84,12 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import uk.gov.justice.services.messaging.JsonObjects; +import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; import javax.json.JsonReader; @@ -129,6 +135,10 @@ public class ListingCommandApiTest { private HearingSlotsService hearingSlotsService; @Mock private HearingEnrichmentOrchestrator hearingEnrichmentOrchestrator; + @Mock + private CourtSchedulerServiceAdapter courtSchedulerServiceAdapter; + @Mock + private HearingLookupService hearingLookupService; private static final Type HEARING_TYPE = Type.type() .withId(fromString("6e1bef55-7e13-4615-b3ba-8663f4438e16")) @@ -587,6 +597,177 @@ public void shouldVacateTheTrial() { verify(sender, times(1)).send(senderJsonEnvelopeCaptor.capture()); } + @Test + public void shouldRejectMoveHearingToPastDateWhenHearingIdUnknown() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn("2026-05-01"); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.empty()); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("HEARING_ID_NOT_FOUND")); + verify(sender, never()).send(any()); + } + + @Test + public void shouldMoveMagistratesHearingToPastDateEnrichWithSlotDetailsAndSend() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final UUID courtScheduleId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .add("estimatedMinutes", 30) + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final MoveHearingToPastDateResult slot = new MoveHearingToPastDateResult(courtScheduleId, + "9d324f4f-6c3b-451f-ac1e-f459db781153", startDate, "2026-05-01T09:00:00Z", "2026-05-01T17:00:00Z", 30); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30)).willReturn(slot); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter).moveHearingToPastDate(hearingId, courtCentreId, startDate, 30); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("hearingId"), is(hearingId.toString())); + assertThat(sent.getString("jurisdiction"), is("MAGISTRATES")); + assertThat(sent.getString("courtScheduleId"), is(courtScheduleId.toString())); + assertThat(sent.getString("sessionDate"), is(startDate.toString())); + assertThat(sent.getInt("durationInMinutes"), is(30)); + } + + @Test + public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2999-01-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final JsonObject body = Json.createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "Hearings can only be moved to today or an earlier date") + .build(); + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) + .willThrow(new MoveHearingToPastDateException(422, body, "rejected")); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + verify(sender, never()).send(any()); + } + + @Test + public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "MAGISTRATES") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) + .willThrow(new MoveHearingToPastDateException(404, Json.createObjectBuilder().build(), "not found")); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + assertThat(thrown.getHttpStatus(), is(404)); + verify(sender, never()).send(any()); + } + + @Test + public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.now().minusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("hearingId"), is(hearingId.toString())); + assertThat(sent.getString("jurisdiction"), is("CROWN")); + assertThat(sent.getString("startDate"), is(startDate.toString())); + assertThat(sent.containsKey("courtScheduleId"), is(false)); + } + + @Test + public void shouldRejectCrownMoveToFutureDate() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final LocalDate startDate = LocalDate.now().plusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, + () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); + + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, never()).send(any()); + } + @Test public void shouldListCourtHearing() { diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java new file mode 100644 index 000000000..250c9ae4a --- /dev/null +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java @@ -0,0 +1,23 @@ +package uk.gov.moj.cpp.listing.command.api.accesscontrol; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; +import static uk.gov.moj.cpp.listing.command.api.util.FileUtil.getPayload; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class PermissionConstantsTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void shouldCreateChangeHearingToPastDatePermission() throws JsonProcessingException { + JsonNode actual = mapper.readTree(createChangeHearingToPastDatePermission()); + JsonNode expected = mapper.readTree(getPayload("create-change-hearing-to-past-date-permission.json")); + assertThat(actual, is(expected)); + } +} diff --git a/listing-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..fceeefee4 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,11 @@ 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 CROWN_JURISDICTION = "CROWN"; @Inject private EventSource eventSource; @@ -406,6 +411,31 @@ public void vacateTrial(final JsonEnvelope command) throws EventStreamException updateHearingEventStream(command, vacateTrialEnriched.getHearingId(), (Hearing hearing) -> hearing.vacateTrial(vacateTrialEnriched.getHearingId(), vacateTrialEnriched.getVacatedTrialReasonId())); } + @Handles("listing.command.move-hearing-to-past-date-enriched") + public void moveHearingToPastDate(final JsonEnvelope command) throws EventStreamException { + + LOGGER.info("'listing.command.move-hearing-to-past-date-enriched' received with payload {}", command.toObfuscatedDebugString()); + + final JsonObject payload = command.payloadAsJsonObject(); + final UUID hearingId = fromString(payload.getString(HEARING_ID)); + final String jurisdiction = payload.getString(JURISDICTION); + + if (CROWN_JURISDICTION.equals(jurisdiction)) { + // Listing-side-only re-date (Baris decision D1) - courtscheduler was never called for CROWN, + // so re-use the existing changeStartDate aggregate method rather than the courtschedule-slot event. + final LocalDate startDate = parse(payload.getString(START_DATE)); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); + } else { + final HearingDayCourtSchedule hearingDayCourtSchedule = HearingDayCourtSchedule.hearingDayCourtSchedule() + .withCourtScheduleId(fromString(payload.getString(COURT_SCHEDULE_ID))) + .withHearingDate(parse(payload.getString(SESSION_DATE))) + .build(); + + updateHearingEventStream(command, hearingId, + hearing -> hearing.raiseHearingDayCourtSchedulesUpdated(hearingId, List.of(hearingDayCourtSchedule))); + } + } + @Handles("listing.command.hearing-vacate-trial") public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException { LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString()); diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..b31a131ee --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,11 @@ +{ + "hearingId": "0baecac5-222b-402d-9047-84803679edae", + "jurisdiction": "MAGISTRATES", + "startDate": "2026-05-01", + "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", + "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153", + "sessionDate": "2026-05-01", + "sessionStartTime": "2026-05-01T09:00:00Z", + "sessionEndTime": "2026-05-01T17:00:00Z", + "durationInMinutes": 30 +} diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json new file mode 100644 index 000000000..562ab41be --- /dev/null +++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date-enriched.json", + "type": "object", + "properties": { + "hearingId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "jurisdiction": { + "type": "string", + "enum": ["CROWN", "MAGISTRATES"] + }, + "startDate": { + "type": "string", + "format": "date" + }, + "courtScheduleId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, + "courtRoomId": { + "type": "string" + }, + "sessionDate": { + "type": "string", + "format": "date" + }, + "sessionStartTime": { + "type": "string" + }, + "sessionEndTime": { + "type": "string" + }, + "durationInMinutes": { + "type": "integer" + } + }, + "required": [ + "hearingId", + "jurisdiction", + "startDate" + ], + "additionalProperties": false +} diff --git a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml index 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..e0e06b067 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -99,6 +99,7 @@ import uk.gov.justice.listing.events.CaseEjected; import uk.gov.justice.listing.events.CaseIdentifierUpdated; import uk.gov.justice.listing.events.CasesAddedToHearing; +import uk.gov.justice.listing.events.HearingDayCourtSchedule; import uk.gov.justice.listing.events.CourtApplicationAddedToHearing; import uk.gov.justice.listing.events.CourtApplicationToBeUpdated; import uk.gov.justice.listing.events.CourtListRestricted; @@ -2549,6 +2550,40 @@ public void listingCommandHandlerShouldVacateTrial() throws Exception { verify(hearing, times(1)).vacateTrial(HEARING_ID_1, REASON); } + @Test + public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws Exception { + final UUID courtScheduleId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveHearingToPastDate(courtScheduleId, "2026-05-01"); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), any())).thenReturn(mock(Stream.class)); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), captor.capture()); + verify(hearing, never()).changeStartDate(any(), any()); + final HearingDayCourtSchedule applied = captor.getValue().get(0); + assertThat(applied.getCourtScheduleId(), is(courtScheduleId)); + assertThat(applied.getHearingDate(), is(LocalDate.parse("2026-05-01"))); + } + + @Test + public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception { + final String startDate = "2026-05-01"; + final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate); + + when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); + when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); + when(hearing.changeStartDate(LocalDate.parse(startDate), HEARING_ID_1)).thenReturn(mock(Stream.class)); + + listingCommandHandler.moveHearingToPastDate(commandEnvelope); + + verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + } + @Test public void listingCommandHandlerShouldHearingVacateTrial() throws Exception { final JsonEnvelope commandEnvelope = getEnvelopeForHearingVacateTrial(REASON); @@ -2739,6 +2774,19 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) { return createEnvelope("listing.command.vacate-trial-enriched", jsonReader.readObject()); } + private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"" + + sessionDate + "\",\"courtScheduleId\":\"" + courtScheduleId + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + + private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + "\"}"; + final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); + return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); + } + private JsonEnvelope getEnvelopeForHearingVacateTrial(final UUID reason) { final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"vacatedTrialReasonId\":\"" + reason + "\"}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java new file mode 100644 index 000000000..f8fe8601c --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java @@ -0,0 +1,38 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import static uk.gov.justice.services.messaging.JsonObjects.getString; + +import javax.json.JsonObject; + +/** + * Raised when courtscheduler rejects a move-hearing-to-past-date request (422/404), or when the + * listing side rejects the request before ever calling courtscheduler (unknown hearingId, future + * date on the CROWN listing-side path). Carries the upstream HTTP status and body so the + * {@code MoveHearingToPastDateExceptionMapper} can render an equivalent response back to the + * caller. + */ +public class MoveHearingToPastDateException extends RuntimeException { + + private final int httpStatus; + private final JsonObject responseBody; + private final String errorCode; + + public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) { + super(message); + this.httpStatus = httpStatus; + this.responseBody = responseBody; + this.errorCode = responseBody == null ? null : getString(responseBody, "errorCode").orElse(null); + } + + public int getHttpStatus() { + return httpStatus; + } + + public JsonObject getResponseBody() { + return responseBody; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java new file mode 100644 index 000000000..6d35209ed --- /dev/null +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java @@ -0,0 +1,18 @@ +package uk.gov.moj.cpp.listing.common.pastdate; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Purpose-built result of a successful {@code courtscheduler.move-hearing-to-past-date} call for + * the MAGISTRATES path. Deliberately narrow — unlike the ccsph2n-only {@code CrownFallbackResult} + * this branch does not carry any crown-fallback/search-and-book concerns, only the slot details + * needed to enrich {@code listing.command.move-hearing-to-past-date-enriched}. + */ +public record MoveHearingToPastDateResult(UUID courtScheduleId, + String courtRoomId, + LocalDate sessionDate, + String sessionStartTime, + String sessionEndTime, + Integer durationInMinutes) { +} diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java index b493a0dab..502c7646c 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,11 @@ 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"; @Inject private HearingSlotsService hearingSlotsService; @Inject @@ -220,4 +229,49 @@ HearingIdsResponse getHearingIds(final Response response) { return new HearingIdsResponse(uuids, results, pageCount); } + + /** + * MAGISTRATES-only. Calls courtscheduler's {@code move-hearing-to-past-date} action + * synchronously. CROWN moves are handled entirely listing-side and never reach this method + * (Baris decision D1). On any non-200 response the upstream errorCode/status is surfaced via + * {@link MoveHearingToPastDateException} so the caller sends no event. + */ + public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, + final UUID courtCentreId, + final LocalDate startDate, + final Integer durationInMinutes) { + final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() + .add(HEARING_ID, hearingId.toString()) + .add(COURT_CENTRE_ID, courtCentreId.toString()) + .add(JURISDICTION, MAGISTRATES_JURISDICTION) + .add(START_DATE, startDate.toString()); + if (durationInMinutes != null) { + requestBuilder.add(DURATION_IN_MINUTES, durationInMinutes); + } + + final Response response = hearingSlotsService.moveHearingToPastDate(hearingId, requestBuilder.build()); + final int status = response.getStatus(); + final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject) + ? (JsonObject) response.getEntity() + : Json.createObjectBuilder().build(); + + if (HttpStatus.SC_OK == status) { + return parseMoveHearingToPastDateResult(body); + } + + LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", + status, hearingId, body); + throw new MoveHearingToPastDateException(status, body, + "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); + } + + private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { + return new MoveHearingToPastDateResult( + body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, + body.getString("courtRoomId", null), + body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, + body.getString("sessionStartTime", null), + body.getString("sessionEndTime", null), + body.containsKey("durationInMinutes") ? body.getInt("durationInMinutes") : null); + } } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java index 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..b67029436 --- /dev/null +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java @@ -0,0 +1,118 @@ +package uk.gov.moj.cpp.listing.common.service; + +import static javax.json.Json.createObjectBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; +import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.json.JsonObject; +import javax.ws.rs.core.Response; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CourtSchedulerServiceAdapterMoveHearingToPastDateTest { + + @InjectMocks + private CourtSchedulerServiceAdapter adapter; + + @Mock + private HearingSlotsService hearingSlotsService; + + @Mock + private Response response; + + @Test + void shouldParseSlotDetailsOn200() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final UUID courtScheduleId = UUID.randomUUID(); + final LocalDate startDate = LocalDate.parse("2026-05-01"); + + final JsonObject body = createObjectBuilder() + .add("hearingId", hearingId.toString()) + .add("courtScheduleId", courtScheduleId.toString()) + .add("courtRoomId", "9d324f4f-6c3b-451f-ac1e-f459db781153") + .add("sessionDate", "2026-05-01") + .add("sessionStartTime", "2026-05-01T09:00:00Z") + .add("sessionEndTime", "2026-05-01T17:00:00Z") + .add("durationInMinutes", 30) + .add("source", "MOVE_TO_PAST_DATE") + .build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30); + + assertThat(result.courtScheduleId(), is(courtScheduleId)); + assertThat(result.courtRoomId(), is("9d324f4f-6c3b-451f-ac1e-f459db781153")); + assertThat(result.sessionDate(), is(startDate)); + assertThat(result.sessionStartTime(), is("2026-05-01T09:00:00Z")); + assertThat(result.sessionEndTime(), is("2026-05-01T17:00:00Z")); + assertThat(result.durationInMinutes(), is(30)); + } + + @Test + void shouldOmitDurationInRequestWhenNotSupplied() { + final UUID hearingId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, UUID.randomUUID(), LocalDate.parse("2026-05-01"), null); + + assertThat(result.durationInMinutes(), is(nullValue())); + } + + @Test + void shouldThrowWith422AndErrorCodeWhenFutureDate() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "FUTURE_DATE_NOT_ALLOWED") + .add("message", "must not be after today") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2999-01-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldThrowWith404WhenNoSession() { + final JsonObject body = createObjectBuilder().build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(HttpStatus.SC_NOT_FOUND)); + } +} diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java index 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..45252961a --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -0,0 +1,145 @@ +package uk.gov.moj.cpp.listing.it; + +import static java.util.UUID.randomUUID; +import static javax.ws.rs.core.Response.Status.ACCEPTED; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; +import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled; + +import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.Test; + +/** + * Covers listing.command.move-hearing-to-past-date: MAGISTRATES wraps courtscheduler synchronously + * and stores the returned slot as enrichment; CROWN is listing-side-only (Baris decision D1) and + * never calls courtscheduler. Single-day only. + */ +class MoveHearingToPastDateIT extends AbstractIT { + + private static final String COURT_ROOM_ID = "731816"; + + @Test + void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final LocalDate pastDate = ItClock.today().minusDays(1); + final String courtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateCalled(moveSteps.getHearingId()); + moveSteps.verifyCourtScheduleStored(courtScheduleId); + } + + @Test + void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final String firstCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), firstCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(firstCourtScheduleId); + + final String secondCourtScheduleId = randomUUID().toString(); + stubMoveHearingToPastDate(moveSteps.getHearingId(), secondCourtScheduleId, COURT_ROOM_ID, pastDate, 30); + assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode())); + moveSteps.verifyCourtScheduleStored(secondCourtScheduleId); + } + + @Test + void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED", + "Hearings can only be moved to today or an earlier date"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + } + + @Test + void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(404)); + } + + @Test + void shouldRejectMoveWith422WhenHearingIdUnknown() { + // A hearing that was never listed - MoveHearingToPastDateSteps still needs SOME allocated + // hearing to obtain a courtCentreId, but we submit against a random unknown hearingId. + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final UUID unknownHearingId = randomUUID(); + + final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("HEARING_ID_NOT_FOUND")); + verifyMoveHearingToPastDateNeverCalled(unknownHearingId.toString()); + } + + @Test + void shouldRejectMoveWith400WhenMandatoryFieldMissing() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(400)); + } + + @Test + void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final LocalDate pastDate = ItClock.today().minusDays(1); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); + + assertThat(response.getStatus(), is(ACCEPTED.getStatusCode())); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + moveSteps.verifyStartDateUpdated(pastDate); + } + + @Test + void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); + final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED")); + verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId()); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java new file mode 100644 index 000000000..74adbf7b4 --- /dev/null +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -0,0 +1,111 @@ +package uk.gov.moj.cpp.listing.steps; + +import static com.jayway.jsonpath.matchers.JsonPathMatchers.withJsonPath; +import static java.text.MessageFormat.format; +import static javax.ws.rs.core.Response.Status.OK; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static uk.gov.justice.services.common.http.HeaderConstants.USER_ID; +import static uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder.requestParams; +import static uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher.payload; +import static uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher.status; +import static uk.gov.moj.cpp.listing.it.util.RestPollerHelper.pollWithDefaults; +import static uk.gov.moj.cpp.listing.utils.FileUtil.getPayload; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; +import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; +import static uk.gov.moj.cpp.listing.utils.WireMockStubUtils.setupLoggedInUserPermissionsWithChangeHearingToPastDate; + +import uk.gov.moj.cpp.listing.it.AbstractIT; +import uk.gov.moj.cpp.listing.steps.data.HearingData; +import uk.gov.moj.cpp.listing.steps.data.HearingsData; + +import java.time.LocalDate; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +/** + * Steps for the listing.command.move-hearing-to-past-date wrapper endpoint. Same + * {@code POST /hearings/{hearingId}} resource as vacate-trial/extend-hearing, distinguished by + * media type {@code application/vnd.listing.command.move-hearing-to-past-date+json}. + */ +public class MoveHearingToPastDateSteps extends AbstractIT { + + private static final String LISTING_QUERY_HEARING = "listing.search.hearing"; + private static final String MEDIA_TYPE_SEARCH_HEARING = "application/vnd.listing.search.hearing+json"; + private static final String LISTING_COMMAND_MOVE = "listing.command.move-hearing-to-past-date"; + private static final String MEDIA_TYPE_MOVE = "application/vnd.listing.command.move-hearing-to-past-date+json"; + + private final String hearingId; + private final UUID courtCentreId; + + public MoveHearingToPastDateSteps(final HearingsData hearingsData) { + final HearingData hearingData = hearingsData.getHearingData().get(0); + this.hearingId = hearingData.getId().toString(); + this.courtCentreId = hearingData.getCourtCentreId(); + givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); + setupLoggedInUserPermissionsWithChangeHearingToPastDate(); + } + + public String getHearingId() { + return hearingId; + } + + public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) { + final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json") + .replace("%%HEARING_ID%%", hearingId) + .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString()) + .replace("%%START_DATE%%", date.toString()); + + return postMove(payload); + } + + public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { + final String payload = "{\"hearingId\":\"" + hearingId + "\",\"startDate\":\"" + date + "\"}"; + return postMove(hearingId, payload); + } + + /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this + * steps' own courtCentreId so only the hearingId lookup is exercised. */ + public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) { + final String payload = "{\"hearingId\":\"" + otherHearingId + "\",\"courtCentreId\":\"" + courtCentreId + + "\",\"startDate\":\"" + date + "\"}"; + return postMove(otherHearingId.toString(), payload); + } + + private Response postMove(final String payload) { + return postMove(hearingId, payload); + } + + private Response postMove(final String targetHearingId, final String payload) { + final String url = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_COMMAND_MOVE), targetHearingId)); + return restClient.postCommand(url, MEDIA_TYPE_MOVE, payload, getLoggedInHeader()); + } + + public void verifyCourtScheduleStored(final String expectedCourtScheduleId) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.hearingDays[*].courtScheduleId", hasItem(expectedCourtScheduleId)) + ))); + } + + public void verifyStartDateUpdated(final LocalDate expectedStartDate) { + final String searchHearingUrl = String.format("%s/%s", getBaseUri(), + format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId)); + + pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build()) + .until( + status().is(OK), + payload().isJson(org.hamcrest.CoreMatchers.allOf( + withJsonPath("$.id", is(hearingId)), + withJsonPath("$.startDate", is(expectedStartDate.toString())) + ))); + } +} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java index bfca77eb2..0da555232 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,67 @@ private static void stubCourtSchedulesByIdResponse(final String body) { .withHeader(CONTENT_TYPE, APPLICATION_JSON) )); } + + // --- move-hearing-to-past-date stubs (MAGISTRATES-only: CROWN never reaches courtscheduler, Baris decision D1) --- + + /** Stub a successful POST /hearings/{hearingId} move-hearing-to-past-date response. */ + public static void stubMoveHearingToPastDate(final String hearingId, + final String courtScheduleId, + final String courtRoomId, + final LocalDate sessionDate, + final int durationInMinutes) { + final String startTime = sessionDate + "T09:00:00Z"; + final String endTime = sessionDate + "T17:00:00Z"; + final String body = format( + "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," + + "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," + + "\"durationInMinutes\":%s}", + hearingId, courtScheduleId, courtRoomId, sessionDate, startTime, endTime, durationInMinutes); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withBody(body) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Stub a courtscheduler rejection (422 FUTURE_DATE_NOT_ALLOWED or 404 no-session) for move-hearing-to-past-date. */ + public static void stubMoveHearingToPastDateFailure(final String hearingId, + final int statusCode, + final String errorCode, + final String message) { + final StringBuilder body = new StringBuilder("{"); + if (errorCode != null) { + body.append("\"errorCode\":\"").append(errorCode).append("\","); + } + body.append("\"message\":\"").append(message).append("\"}"); + + stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) + .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) + .willReturn(aResponse().withStatus(statusCode) + .withBody(body.toString()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON))); + } + + /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing. */ + public static void verifyMoveHearingToPastDateCalled(final String hearingId) { + Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { + try { + WireMock.verify(WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)) + .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\""))); + return true; + } catch (VerificationException e) { + return false; + } + }); + } + + /** Regression guard for the CROWN listing-side-only path: courtscheduler must never be called. */ + public static void verifyMoveHearingToPastDateNeverCalled(final String hearingId) { + WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching( + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); + } } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 3668ccd21..36fb58c3c 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -62,6 +62,28 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } + /** + * Overrides the empty-permissions catch-all ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}, + * registered in {@code AbstractIT.setUp()} at priority 1) so the "Change hearing to past date"/"Link" + * permission checked by the move-hearing-to-past-date DRL rule resolves to true. Same priority + same + * Accept-header specificity as the catch-all — WireMock resolves same-priority ties in favour of the + * most-recently-registered stub, and this is registered later (from the test's Steps constructor). + */ + public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { + stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) + .atPriority(1) + .withHeader("Accept", containing("application/vnd.usersgroups.is-logged-in-user-has-permission-for-action+json")) + .willReturn(aResponse().withStatus(OK.getStatusCode()) + .withHeader("CPPID", randomUUID().toString()) + .withHeader("Content-Type", "application/json") + .withBody(String.valueOf(createObjectBuilder() + .add("groups", createArrayBuilder()) + .add("switchableRoles", createArrayBuilder()) + .add("permissions", createArrayBuilder() + .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) + .build())))); + } + public static void setupAsUnauthorisedUser(final UUID userId) { stubPingFor("usersgroups-service"); diff --git a/listing-integration-test/src/test/resources/endpoint.properties b/listing-integration-test/src/test/resources/endpoint.properties index 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..4bc623ddd --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "%%HEARING_ID%%", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +} diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json new file mode 100644 index 000000000..4bc623ddd --- /dev/null +++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json @@ -0,0 +1,5 @@ +{ + "hearingId": "%%HEARING_ID%%", + "courtCentreId": "%%COURT_CENTRE_ID%%", + "startDate": "%%START_DATE%%" +} From d715deba8537dab2c7ed0d3a10509faaea684ddf Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:38:31 +0100 Subject: [PATCH 02/10] fix(SPRDT-987): stub get-logged-in-user-permissions query for hasPermission DRL path in move IT --- .../moj/cpp/listing/utils/WireMockStubUtils.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 36fb58c3c..631025dcf 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -63,22 +63,21 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } /** - * Overrides the empty-permissions catch-all ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}, - * registered in {@code AbstractIT.setUp()} at priority 1) so the "Change hearing to past date"/"Link" - * permission checked by the move-hearing-to-past-date DRL rule resolves to true. Same priority + same - * Accept-header specificity as the catch-all — WireMock resolves same-priority ties in favour of the - * most-recently-registered stub, and this is registered later (from the test's Steps constructor). + * Serves {@code UserAndGroupProvider.getUserPermissionsByUserId} (the {@code hasPermission} DRL path, + * Accept {@code application/vnd.usersgroups.get-logged-in-user-permissions+json}) so the + * "Change hearing to past date"/"Link" permission checked by the move-hearing-to-past-date rule + * resolves to true. Matches on URL alone — the empty-permissions catch-all + * ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}) only matches the + * is-logged-in-user-has-permission-for-action Accept header, so without this stub the permissions + * query gets no match and the provider receives a NULL payload. */ public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) .atPriority(1) - .withHeader("Accept", containing("application/vnd.usersgroups.is-logged-in-user-has-permission-for-action+json")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withHeader("CPPID", randomUUID().toString()) .withHeader("Content-Type", "application/json") .withBody(String.valueOf(createObjectBuilder() - .add("groups", createArrayBuilder()) - .add("switchableRoles", createArrayBuilder()) .add("permissions", createArrayBuilder() .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) .build())))); From 33655baa9f82d5ab2f7365653d55e31e2c92bf60 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 19:52:51 +0100 Subject: [PATCH 03/10] fix(SPRDT-987): list hearings through the real flow before moving them in MoveHearingToPastDateIT --- .../listing/it/MoveHearingToPastDateIT.java | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index 45252961a..e41a14fbb 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -8,16 +8,22 @@ import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessions; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure; +import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubProvisionalBookingWithCustomParams; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled; import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled; import uk.gov.moj.cpp.listing.it.util.ItClock; +import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps; import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps; import uk.gov.moj.cpp.listing.steps.data.HearingsData; import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import javax.ws.rs.core.Response; @@ -33,10 +39,44 @@ class MoveHearingToPastDateIT extends AbstractIT { private static final String COURT_ROOM_ID = "731816"; + /** + * Lists a real hearing through the full flow (command → events → viewstore projection) and only + * returns once it is queryable — the move command's HEARING_ID_NOT_FOUND pre-check reads the + * viewstore, so moving an un-listed hearing is legitimately rejected. Mirrors VacateHearingIT: + * MAGS listing needs the provisional-booking + list-hearing-in-court-sessions stubs; CROWN + * listing never calls courtscheduler pre-Phase-2. + */ + private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction) { + final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(jurisdiction); + final ListCourtHearingSteps listCourtHearingSteps = new ListCourtHearingSteps(hearingsData); + + if (MAGISTRATES_JURISDICTION.equals(jurisdiction)) { + final ZonedDateTime hearingStartTime = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getHearingStartTime(); + final UUID courtCentreId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtCentreId(); + final UUID courtroomId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtRoomId(); + final String listedCourtScheduleId = randomUUID().toString(); + + final Map stubParams = new HashMap<>(); + stubParams.put("SESSION_DATE", hearingStartTime.toLocalDate().toString()); + stubParams.put("COURT_CENTRE_ID", courtCentreId.toString()); + stubParams.put("COURT_SCHEDULE_ID", listedCourtScheduleId); + stubParams.put("COURT_ROOM_ID", courtroomId.toString()); + stubParams.put("BOOKING_ID", randomUUID().toString()); + stubParams.put("HEARING_START_TIME", hearingStartTime.toString()); + stubProvisionalBookingWithCustomParams(stubParams); + stubListHearingInCourtSessions(listCourtHearingSteps.getHearingsData().getHearingData().get(0).getId().toString(), + listedCourtScheduleId, hearingStartTime); + } + + listCourtHearingSteps.whenCaseIsSubmittedForListing(); + listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED); + + return new MoveHearingToPastDateSteps(hearingsData); + } + @Test void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final String courtScheduleId = randomUUID().toString(); @@ -51,8 +91,7 @@ void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() { @Test void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final String firstCourtScheduleId = randomUUID().toString(); @@ -68,8 +107,7 @@ void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() { @Test void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED", "Hearings can only be moved to today or an earlier date"); @@ -82,8 +120,7 @@ void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAll @Test void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, "No court-schedule session found for the given date and court centre"); @@ -120,8 +157,7 @@ void shouldRejectMoveWith400WhenMandatoryFieldMissing() { @Test void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); final LocalDate pastDate = ItClock.today().minusDays(1); final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate); @@ -133,8 +169,7 @@ void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler @Test void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() { - final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(CROWN_JURISDICTION); - final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData); + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION); final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1)); From 6b2dff2d87f8c1870ab255b047ed6467c6643639 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 20:14:25 +0100 Subject: [PATCH 04/10] fix(SPRDT-987): MAGS move re-issues the hearing day on the past date (changeStartDate + assignHearingDaysV2) - the court-schedule event alone cannot re-date a day --- .../command/api/ListingCommandApi.java | 3 +- .../handler/ListingCommandHandler.java | 40 ++++++++++++++++--- ...nd.move-hearing-to-past-date-enriched.json | 1 + ...nd.move-hearing-to-past-date-enriched.json | 6 ++- .../handler/ListingCommandHandlerTest.java | 22 ++++++---- .../listing/it/MoveHearingToPastDateIT.java | 2 +- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 5cd377137..44b42292a 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 @@ -377,7 +377,8 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { final JsonObjectBuilder enrichedBuilder = createObjectBuilder() .add(HEARING_ID, hearingId.toString()) .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType) - .add(START_DATE, startDate.toString()); + .add(START_DATE, startDate.toString()) + .add(COURT_CENTRE_ID, courtCentreId.toString()); if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index fceeefee4..dafe77f56 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 @@ -193,6 +193,11 @@ public class ListingCommandHandler { private static final String START_DATE = "startDate"; private static final String COURT_SCHEDULE_ID = "courtScheduleId"; private static final String SESSION_DATE = "sessionDate"; + private static final String MOVE_COURT_CENTRE_ID = "courtCentreId"; + private static final String MOVE_COURT_ROOM_ID = "courtRoomId"; + private static final String SESSION_START_TIME = "sessionStartTime"; + private static final String SESSION_END_TIME = "sessionEndTime"; + private static final String DURATION_IN_MINUTES = "durationInMinutes"; private static final String CROWN_JURISDICTION = "CROWN"; @Inject @@ -426,13 +431,38 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream final LocalDate startDate = parse(payload.getString(START_DATE)); updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); } else { - final HearingDayCourtSchedule hearingDayCourtSchedule = HearingDayCourtSchedule.hearingDayCourtSchedule() - .withCourtScheduleId(fromString(payload.getString(COURT_SCHEDULE_ID))) - .withHearingDate(parse(payload.getString(SESSION_DATE))) + final LocalDate startDate = parse(payload.getString(START_DATE)); + final LocalDate sessionDate = parse(payload.getString(SESSION_DATE)); + final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); + final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); + final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) + : sessionDate.atStartOfDay(java.time.ZoneOffset.UTC); + final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; + final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) + ? payload.getInt(DURATION_IN_MINUTES) : null; + + // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot + // move a day to a new date. Re-issue the single day on the past date carrying the booked slot. + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() + .withHearingDate(sessionDate) + .withStartTime(dayStartTime) + .withEndTime(dayEndTime) + .withDurationMinutes(durationInMinutes) + .withSequence(1) + .withCourtScheduleId(Optional.of(courtScheduleId)) + .withCourtCentreId(courtCentreId) + .withCourtRoomId(courtRoomId) .build(); - updateHearingEventStream(command, hearingId, - hearing -> hearing.raiseHearingDayCourtSchedulesUpdated(hearingId, List.of(hearingDayCourtSchedule))); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES, emptyList()))); } } diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json index b31a131ee..dca55cc54 100644 --- a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json +++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json @@ -2,6 +2,7 @@ "hearingId": "0baecac5-222b-402d-9047-84803679edae", "jurisdiction": "MAGISTRATES", "startDate": "2026-05-01", + "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2", "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153", "sessionDate": "2026-05-01", diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json index 562ab41be..7de8fda09 100644 --- a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json +++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json @@ -14,6 +14,9 @@ "type": "string", "format": "date" }, + "courtCentreId": { + "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" + }, "courtScheduleId": { "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, @@ -37,7 +40,8 @@ "required": [ "hearingId", "jurisdiction", - "startDate" + "startDate", + "courtCentreId" ], "additionalProperties": false } diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index e0e06b067..6f9bdc4ca 100644 --- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java +++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.any; import static org.mockito.Mockito.atLeast; @@ -2557,16 +2558,20 @@ public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); - when(hearing.raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), any())).thenReturn(mock(Stream.class)); + when(hearing.changeStartDate(eq(LocalDate.parse("2026-05-01")), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList()))).thenReturn(Stream.empty()); listingCommandHandler.moveHearingToPastDate(commandEnvelope); - final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(hearing, times(1)).raiseHearingDayCourtSchedulesUpdated(eq(HEARING_ID_1), captor.capture()); - verify(hearing, never()).changeStartDate(any(), any()); - final HearingDayCourtSchedule applied = captor.getValue().get(0); - assertThat(applied.getCourtScheduleId(), is(courtScheduleId)); - assertThat(applied.getHearingDate(), is(LocalDate.parse("2026-05-01"))); + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(hearing, times(1)).changeStartDate(LocalDate.parse("2026-05-01"), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList())); + verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getCourtScheduleId().orElse(null), is(courtScheduleId)); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse("2026-05-01"))); } @Test @@ -2776,7 +2781,8 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) { private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) { final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\"" - + sessionDate + "\",\"courtScheduleId\":\"" + courtScheduleId + "\",\"sessionDate\":\"" + sessionDate + "\"}"; + + sessionDate + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtScheduleId\":\"" + courtScheduleId + + "\",\"sessionDate\":\"" + sessionDate + "\"}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index e41a14fbb..8f4081b85 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -37,7 +37,7 @@ */ class MoveHearingToPastDateIT extends AbstractIT { - private static final String COURT_ROOM_ID = "731816"; + private static final String COURT_ROOM_ID = "731816c1-27ea-4711-8d92-0a1c2f3ab7de"; /** * Lists a real hearing through the full flow (command → events → viewstore projection) and only From 8c33c020fd74c4fdf16503a74c3c3d26d17ce4c8 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 20:50:56 +0100 Subject: [PATCH 05/10] refactor(SPRDT-987): sonar criticals - extract move enrichment helpers (S3776), reuse adapter constants (S1192), transient responseBody (S1948) --- .../command/api/ListingCommandApi.java | 67 +++++++++++-------- .../MoveHearingToPastDateException.java | 4 +- .../service/CourtSchedulerServiceAdapter.java | 4 +- 3 files changed, 43 insertions(+), 32 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 44b42292a..9340e13db 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 @@ -382,42 +382,51 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. - if (startDate.isAfter(LocalDate.now())) { - throw new MoveHearingToPastDateException(422, - buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), - "Hearings can only be moved to today or an earlier date"); - } + rejectCrownMoveToFutureDate(startDate); } else { - final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) - ? hearing.getInt(ESTIMATED_MINUTES) : null; - - final MoveHearingToPastDateResult slot = - courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); - - if (slot.courtScheduleId() != null) { - enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); - } - if (slot.courtRoomId() != null) { - enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); - } - if (slot.sessionDate() != null) { - enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); - } - if (slot.sessionStartTime() != null) { - enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); - } - if (slot.sessionEndTime() != null) { - enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); - } - if (slot.durationInMinutes() != null) { - enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); - } + enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing); } sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED), enrichedBuilder.build())); } + private static void rejectCrownMoveToFutureDate(final LocalDate startDate) { + if (startDate.isAfter(LocalDate.now())) { + throw new MoveHearingToPastDateException(422, + buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"), + "Hearings can only be moved to today or an earlier date"); + } + } + + private void enrichWithBookedPastDateSlot(final JsonObjectBuilder enrichedBuilder, final UUID hearingId, + final UUID courtCentreId, final LocalDate startDate, final JsonObject hearing) { + final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES)) + ? hearing.getInt(ESTIMATED_MINUTES) : null; + + final MoveHearingToPastDateResult slot = + courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes); + + if (slot.courtScheduleId() != null) { + enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString()); + } + if (slot.courtRoomId() != null) { + enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId()); + } + if (slot.sessionDate() != null) { + enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString()); + } + if (slot.sessionStartTime() != null) { + enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime()); + } + if (slot.sessionEndTime() != null) { + enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime()); + } + if (slot.durationInMinutes() != null) { + enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes()); + } + } + private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) { return createObjectBuilder() .add(ERROR_CODE, errorCode) diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java index f8fe8601c..852a60f49 100644 --- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java +++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java @@ -13,8 +13,10 @@ */ public class MoveHearingToPastDateException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int httpStatus; - private final JsonObject responseBody; + private final transient JsonObject responseBody; private final String errorCode; public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) { diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java index 502c7646c..375aa2ab1 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 @@ -268,10 +268,10 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) { return new MoveHearingToPastDateResult( body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null, - body.getString("courtRoomId", null), + body.getString(COURT_ROOM_ID, null), body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null, body.getString("sessionStartTime", null), body.getString("sessionEndTime", null), - body.containsKey("durationInMinutes") ? body.getInt("durationInMinutes") : null); + body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null); } } From 52f5ad7e86850fac95b8d28d115ddeea064b5ab4 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 21:11:42 +0100 Subject: [PATCH 06/10] feat(SPRDT-987): CROWN move re-dates the hearing day too - day rebuilt from the hearing's own room/time on the past date --- .../command/api/ListingCommandApi.java | 27 ++++++++ .../command/api/ListingCommandApiTest.java | 39 +++++++++++ .../handler/ListingCommandHandler.java | 69 +++++++++++-------- .../handler/ListingCommandHandlerTest.java | 20 ++++-- .../steps/MoveHearingToPastDateSteps.java | 3 +- 5 files changed, 123 insertions(+), 35 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index 9340e13db..a9cede44e 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java @@ -88,6 +88,9 @@ public class ListingCommandApi { private static final String SESSION_START_TIME = "sessionStartTime"; private static final String SESSION_END_TIME = "sessionEndTime"; private static final String DURATION_IN_MINUTES = "durationInMinutes"; + private static final String HEARING_DAYS = "hearingDays"; + private static final String DAY_START_TIME = "startTime"; + private static final String DAY_DURATION_MINUTES = "durationMinutes"; private static final String ERROR_CODE = "errorCode"; private static final String MESSAGE = "message"; public static final String HEARING_ID_NOT_FOUND = "HEARING_ID_NOT_FOUND"; @@ -383,6 +386,7 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { if (CROWN_JURISDICTION.equals(jurisdictionType)) { // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called. rejectCrownMoveToFutureDate(startDate); + enrichWithExistingDayDetails(enrichedBuilder, hearing, startDate); } else { enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing); } @@ -391,6 +395,29 @@ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) { enrichedBuilder.build())); } + /** + * CROWN moves never call courtscheduler, so the re-dated hearing day is rebuilt from the + * hearing's own current first sitting day — same room and time-of-day, on the new past date. + */ + private static void enrichWithExistingDayDetails(final JsonObjectBuilder enrichedBuilder, final JsonObject hearing, final LocalDate startDate) { + final JsonArray hearingDays = hearing.containsKey(HEARING_DAYS) ? hearing.getJsonArray(HEARING_DAYS) : null; + if (hearingDays == null || hearingDays.isEmpty()) { + return; + } + final JsonObject day = hearingDays.getJsonObject(0); + enrichedBuilder.add(SESSION_DATE, startDate.toString()); + if (day.containsKey(COURT_ROOM_ID) && !day.isNull(COURT_ROOM_ID)) { + enrichedBuilder.add(COURT_ROOM_ID, day.getString(COURT_ROOM_ID)); + } + if (day.containsKey(DAY_START_TIME) && !day.isNull(DAY_START_TIME)) { + enrichedBuilder.add(SESSION_START_TIME, + java.time.ZonedDateTime.parse(day.getString(DAY_START_TIME)).with(startDate).toString()); + } + if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) { + enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES)); + } + } + private static void rejectCrownMoveToFutureDate(final LocalDate startDate) { if (startDate.isAfter(LocalDate.now())) { throw new MoveHearingToPastDateException(422, diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index d1b4cc187..02c84bc33 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java @@ -742,6 +742,45 @@ public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtSc assertThat(sent.containsKey("courtScheduleId"), is(false)); } + @Test + public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() { + final UUID hearingId = randomUUID(); + final UUID courtCentreId = randomUUID(); + final UUID crownRoomId = randomUUID(); + final LocalDate startDate = LocalDate.now().minusDays(1); + + given(envelope.payloadAsJsonObject()).willReturn(payload); + given(payload.getString("hearingId")).willReturn(hearingId.toString()); + given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString()); + given(payload.getString("startDate")).willReturn(startDate.toString()); + given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build()); + + final JsonObject hearing = Json.createObjectBuilder() + .add("id", hearingId.toString()) + .add("jurisdictionType", "CROWN") + .add("hearingDays", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("hearingDate", LocalDate.now().plusDays(3).toString()) + .add("startTime", LocalDate.now().plusDays(3) + "T10:30:00Z") + .add("durationMinutes", 45) + .add("courtRoomId", crownRoomId.toString()))) + .build(); + given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + + final ArgumentCaptor captor = forClass(Envelope.class); + + listingCommandApi.handleMoveHearingToPastDate(envelope); + + verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any()); + verify(sender, times(1)).send(captor.capture()); + final JsonObject sent = (JsonObject) captor.getValue().payload(); + assertThat(sent.getString("sessionDate"), is(startDate.toString())); + assertThat(sent.getString("courtRoomId"), is(crownRoomId.toString())); + assertThat(sent.getString("sessionStartTime"), is(startDate + "T10:30Z")); + assertThat(sent.getInt("durationInMinutes"), is(45)); + assertThat(sent.containsKey("courtScheduleId"), is(false)); + } + @Test public void shouldRejectCrownMoveToFutureDate() { final UUID hearingId = randomUUID(); diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index dafe77f56..41638f740 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 @@ -424,41 +424,23 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream final JsonObject payload = command.payloadAsJsonObject(); final UUID hearingId = fromString(payload.getString(HEARING_ID)); final String jurisdiction = payload.getString(JURISDICTION); + final LocalDate startDate = parse(payload.getString(START_DATE)); + // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot + // move a day to a new date. Both paths re-issue the single day on the past date instead: + // MAGS carries the slot booked by courtscheduler, CROWN carries the hearing's own existing + // room/time (enriched by command-api from its current first day - courtscheduler is never + // called for CROWN before Phase 2, Baris decision D1). if (CROWN_JURISDICTION.equals(jurisdiction)) { - // Listing-side-only re-date (Baris decision D1) - courtscheduler was never called for CROWN, - // so re-use the existing changeStartDate aggregate method rather than the courtschedule-slot event. - final LocalDate startDate = parse(payload.getString(START_DATE)); - updateHearingEventStream(command, hearingId, (Hearing hearing) -> hearing.changeStartDate(startDate, hearingId)); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, startDate, Optional.empty()); + updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( + hearing.changeStartDate(startDate, hearingId), + hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, + uk.gov.justice.core.courts.JurisdictionType.CROWN, emptyList()))); } else { - final LocalDate startDate = parse(payload.getString(START_DATE)); final LocalDate sessionDate = parse(payload.getString(SESSION_DATE)); final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID)); - final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) - ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); - final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) - ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); - final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) - : sessionDate.atStartOfDay(java.time.ZoneOffset.UTC); - final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; - final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) - ? payload.getInt(DURATION_IN_MINUTES) : null; - - // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot - // move a day to a new date. Re-issue the single day on the past date carrying the booked slot. - final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() - .withHearingDate(sessionDate) - .withStartTime(dayStartTime) - .withEndTime(dayEndTime) - .withDurationMinutes(durationInMinutes) - .withSequence(1) - .withCourtScheduleId(Optional.of(courtScheduleId)) - .withCourtCentreId(courtCentreId) - .withCourtRoomId(courtRoomId) - .build(); - + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, sessionDate, Optional.of(courtScheduleId)); updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat( hearing.changeStartDate(startDate, hearingId), hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null, @@ -466,6 +448,33 @@ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStream } } + private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(final JsonObject payload, + final LocalDate dayDate, + final Optional courtScheduleId) { + final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty(); + final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID) + ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty(); + final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) + : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); + final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) + ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; + final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) + ? payload.getInt(DURATION_IN_MINUTES) : null; + + return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() + .withHearingDate(dayDate) + .withStartTime(dayStartTime) + .withEndTime(dayEndTime) + .withDurationMinutes(durationInMinutes) + .withSequence(1) + .withCourtScheduleId(courtScheduleId) + .withCourtCentreId(courtCentreId) + .withCourtRoomId(courtRoomId) + .build(); + } + @Handles("listing.command.hearing-vacate-trial") public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException { LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString()); diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java index 6f9bdc4ca..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 @@ -2577,16 +2577,26 @@ public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws @Test public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception { final String startDate = "2026-05-01"; - final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate); + final UUID crownRoomId = randomUUID(); + final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate, crownRoomId); when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream); when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing); - when(hearing.changeStartDate(LocalDate.parse(startDate), HEARING_ID_1)).thenReturn(mock(Stream.class)); + when(hearing.changeStartDate(eq(LocalDate.parse(startDate)), eq(HEARING_ID_1))).thenReturn(Stream.empty()); + when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList()))).thenReturn(Stream.empty()); listingCommandHandler.moveHearingToPastDate(commandEnvelope); + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1); + verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(), + eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList())); verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any()); + final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0); + assertThat(movedDay.getHearingDate(), is(LocalDate.parse(startDate))); + assertThat(movedDay.getCourtScheduleId().isPresent(), is(false)); + assertThat(movedDay.getCourtRoomId().orElse(null), is(crownRoomId)); } @Test @@ -2787,8 +2797,10 @@ private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtSchedul return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } - private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate) { - final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + "\"}"; + private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate, final UUID courtRoomId) { + final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate + + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtRoomId\":\"" + courtRoomId + + "\",\"sessionDate\":\"" + startDate + "\",\"sessionStartTime\":\"" + startDate + "T10:00:00Z\",\"durationInMinutes\":25}"; final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody)); return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject()); } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index 74adbf7b4..e2ce84aeb 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -105,7 +105,8 @@ public void verifyStartDateUpdated(final LocalDate expectedStartDate) { status().is(OK), payload().isJson(org.hamcrest.CoreMatchers.allOf( withJsonPath("$.id", is(hearingId)), - withJsonPath("$.startDate", is(expectedStartDate.toString())) + withJsonPath("$.startDate", is(expectedStartDate.toString())), + withJsonPath("$.hearingDays[0].hearingDate", is(expectedStartDate.toString())) ))); } } From d205a36841e442c2da687e64a7ba9bd71feca371 Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Thu, 2 Jul 2026 21:26:43 +0100 Subject: [PATCH 07/10] fix(SPRDT-987): always compute endTime on the moved day (event schema requires it); carry existing endTime for CROWN --- .../cpp/listing/command/api/ListingCommandApi.java | 5 +++++ .../command/handler/ListingCommandHandler.java | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java index a9cede44e..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 @@ -90,6 +90,7 @@ public class ListingCommandApi { private static final String DURATION_IN_MINUTES = "durationInMinutes"; private static final String HEARING_DAYS = "hearingDays"; private static final String DAY_START_TIME = "startTime"; + private static final String DAY_END_TIME = "endTime"; private static final String DAY_DURATION_MINUTES = "durationMinutes"; private static final String ERROR_CODE = "errorCode"; private static final String MESSAGE = "message"; @@ -413,6 +414,10 @@ private static void enrichWithExistingDayDetails(final JsonObjectBuilder enriche enrichedBuilder.add(SESSION_START_TIME, java.time.ZonedDateTime.parse(day.getString(DAY_START_TIME)).with(startDate).toString()); } + if (day.containsKey(DAY_END_TIME) && !day.isNull(DAY_END_TIME)) { + enrichedBuilder.add(SESSION_END_TIME, + java.time.ZonedDateTime.parse(day.getString(DAY_END_TIME)).with(startDate).toString()); + } if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) { enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES)); } diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java index 41638f740..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 @@ -458,10 +458,18 @@ private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(fin final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME) ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME)) : dayDate.atStartOfDay(java.time.ZoneOffset.UTC); - final ZonedDateTime dayEndTime = payload.containsKey(SESSION_END_TIME) - ? ZonedDateTime.parse(payload.getString(SESSION_END_TIME)) : null; final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES) ? payload.getInt(DURATION_IN_MINUTES) : null; + // hearing-days-changed-for-hearing requires endTime on every day; the normal listing flows + // always compute it as startTime + duration, so mirror that when the payload has no end time. + final ZonedDateTime dayEndTime; + if (payload.containsKey(SESSION_END_TIME)) { + dayEndTime = ZonedDateTime.parse(payload.getString(SESSION_END_TIME)); + } else if (durationInMinutes != null) { + dayEndTime = dayStartTime.plusMinutes(durationInMinutes); + } else { + dayEndTime = dayStartTime; + } return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay() .withHearingDate(dayDate) From cfa2c69768a8ff10e41523fb65fdfcc65c7e33fa Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 01:39:46 +0100 Subject: [PATCH 08/10] it fix --- .../uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index 8f4081b85..ee65c735a 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -5,6 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static uk.gov.moj.cpp.listing.helper.SearchHearingHelper.pollUntilHearingIsPresent; import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION; import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION; @@ -70,6 +71,12 @@ private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction listCourtHearingSteps.whenCaseIsSubmittedForListing(); listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED); + // verifyHearingListedFromAPI's indefinite json-path filters have no result matcher, so + // they match vacuously against an empty hearings list - it can return before THIS hearing + // is projected. Poll on the hearing id (hasSize(1)) so the move command's viewstore + // pre-check cannot race the hearing-listed projection and 422 with HEARING_ID_NOT_FOUND. + pollUntilHearingIsPresent(hearingsData.getHearingData().get(0).getCourtCentreId().toString(), + ALLOCATED, getLoggedInUser().toString(), hearingsData.getHearingData().get(0).getId().toString()); return new MoveHearingToPastDateSteps(hearingsData); } From 1eb24a022b82a87d360714afc4c2a8d7d359ce1f Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 03:12:25 +0100 Subject: [PATCH 09/10] fix(SPRDT-987): no-session surfaced as 422 NO_SESSION_FOUND (legacy 404 normalised); hearingId dropped from command body (path param only) --- ...ing.command.move-hearing-to-past-date.json | 1 - ...ing.command.move-hearing-to-past-date.json | 4 -- .../command/api/ListingCommandApiTest.java | 11 +++-- .../service/CourtSchedulerServiceAdapter.java | 18 ++++++- ...rviceAdapterMoveHearingToPastDateTest.java | 48 ++++++++++++++++++- .../listing/it/MoveHearingToPastDateIT.java | 20 +++++++- .../steps/MoveHearingToPastDateSteps.java | 9 ++-- .../utils/CourtSchedulerServiceStub.java | 11 ++--- .../move-hearing-to-past-date.json | 1 - .../move-hearing-to-past-date.json | 1 - 10 files changed, 98 insertions(+), 26 deletions(-) diff --git a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json index 1f6e0d5ff..ea5235ca8 100644 --- a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json +++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "0baecac5-222b-402d-9047-84803679edae", "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2", "startDate": "2026-05-01" } diff --git a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json index d77eddfc5..0327ea7fd 100644 --- a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json +++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json @@ -3,9 +3,6 @@ "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date.json", "type": "object", "properties": { - "hearingId": { - "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" - }, "courtCentreId": { "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid" }, @@ -15,7 +12,6 @@ } }, "required": [ - "hearingId", "courtCentreId", "startDate" ], diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java index 02c84bc33..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 @@ -686,7 +686,7 @@ public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() { } @Test - public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { + public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() { final UUID hearingId = randomUUID(); final UUID courtCentreId = randomUUID(); final LocalDate startDate = LocalDate.parse("2026-05-01"); @@ -702,12 +702,17 @@ public void shouldNotSendWhenCourtschedulerReturnsNotFoundForMagistratesMove() { .build(); given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing)); + final JsonObject noSessionBody = Json.createObjectBuilder() + .add("errorCode", "NO_SESSION_FOUND") + .add("message", "No court-schedule session found") + .build(); given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any())) - .willThrow(new MoveHearingToPastDateException(404, Json.createObjectBuilder().build(), "not found")); + .willThrow(new MoveHearingToPastDateException(422, noSessionBody, "no session")); final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class, () -> listingCommandApi.handleMoveHearingToPastDate(envelope)); - assertThat(thrown.getHttpStatus(), is(404)); + assertThat(thrown.getHttpStatus(), is(422)); + assertThat(thrown.getErrorCode(), is("NO_SESSION_FOUND")); verify(sender, never()).send(any()); } diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java index 375aa2ab1..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 @@ -56,6 +56,9 @@ public class CourtSchedulerServiceAdapter { 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 @@ -240,8 +243,8 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, final UUID courtCentreId, final LocalDate startDate, final Integer durationInMinutes) { + // hearingId travels only in the URL path; courtscheduler's REST adapter injects it final JsonObjectBuilder requestBuilder = Json.createObjectBuilder() - .add(HEARING_ID, hearingId.toString()) .add(COURT_CENTRE_ID, courtCentreId.toString()) .add(JURISDICTION, MAGISTRATES_JURISDICTION) .add(START_DATE, startDate.toString()); @@ -261,6 +264,19 @@ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId, LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}", status, hearingId, body); + + if (HttpStatus.SC_NOT_FOUND == status) { + // older courtscheduler releases signal no-session as a bare 404 - normalise to the + // 422 NO_SESSION_FOUND contract so callers see a single failure shape + final JsonObject noSessionBody = Json.createObjectBuilder() + .add(ERROR_CODE, NO_SESSION_FOUND) + .add(MESSAGE, body.getString(MESSAGE, + "No court-schedule session found for hearingId " + hearingId + " on " + startDate)) + .build(); + throw new MoveHearingToPastDateException(HttpStatus.SC_UNPROCESSABLE_ENTITY, noSessionBody, + "moveHearingToPastDate found no session for hearingId " + hearingId); + } + throw new MoveHearingToPastDateException(status, body, "moveHearingToPastDate returned " + status + " for hearingId " + hearingId); } diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java index b67029436..94f6b787e 100644 --- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java +++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException; @@ -21,6 +22,7 @@ import org.apache.http.HttpStatus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -103,7 +105,25 @@ void shouldThrowWith422AndErrorCodeWhenFutureDate() { } @Test - void shouldThrowWith404WhenNoSession() { + void shouldThrowWith422NoSessionFoundWhenCourtschedulerReturns422() { + final JsonObject body = createObjectBuilder() + .add("errorCode", "NO_SESSION_FOUND") + .add("message", "No session available") + .build(); + when(response.getStatus()).thenReturn(422); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response); + + final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, + () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); + + assertThat(ex.getHttpStatus(), is(422)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNormaliseLegacy404ToA422NoSessionFound() { final JsonObject body = createObjectBuilder().build(); when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND); when(response.hasEntity()).thenReturn(true); @@ -113,6 +133,30 @@ void shouldThrowWith404WhenNoSession() { final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class, () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30)); - assertThat(ex.getHttpStatus(), is(HttpStatus.SC_NOT_FOUND)); + assertThat(ex.getHttpStatus(), is(HttpStatus.SC_UNPROCESSABLE_ENTITY)); + assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND")); + } + + @Test + void shouldNotSendHearingIdInRequestBody() { + final UUID hearingId = UUID.randomUUID(); + final UUID courtCentreId = UUID.randomUUID(); + final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString()) + .add("sessionDate", "2026-05-01").build(); + when(response.getStatus()).thenReturn(HttpStatus.SC_OK); + when(response.hasEntity()).thenReturn(true); + when(response.getEntity()).thenReturn(body); + when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response); + + adapter.moveHearingToPastDate(hearingId, courtCentreId, LocalDate.parse("2026-05-01"), 30); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(JsonObject.class); + verify(hearingSlotsService).moveHearingToPastDate(eq(hearingId), requestCaptor.capture()); + final JsonObject request = requestCaptor.getValue(); + assertThat(request.containsKey("hearingId"), is(false)); + assertThat(request.getString("courtCentreId"), is(courtCentreId.toString())); + assertThat(request.getString("jurisdiction"), is("MAGISTRATES")); + assertThat(request.getString("startDate"), is("2026-05-01")); + assertThat(request.getInt("durationInMinutes"), is(30)); } } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java index ee65c735a..0e504871c 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java @@ -126,7 +126,22 @@ void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAll } @Test - void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { + void shouldRejectMagistratesMoveWith422WhenNoCourtScheduleSessionExists() { + final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); + + stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "NO_SESSION_FOUND", + "No court-schedule session found for the given date and court centre"); + + final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); + + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); + } + + /** Older courtscheduler releases signalled no-session as a bare 404 - the listing adapter + * normalises that to the 422 NO_SESSION_FOUND contract. */ + @Test + void shouldNormaliseLegacyCourtscheduler404ToA422NoSessionFound() { final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION); stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null, @@ -134,7 +149,8 @@ void shouldRejectMagistratesMoveWith404WhenNoCourtScheduleSessionExists() { final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1)); - assertThat(response.getStatus(), is(404)); + assertThat(response.getStatus(), is(422)); + assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND")); } @Test diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index e2ce84aeb..bb1a81efc 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -53,7 +53,6 @@ public String getHearingId() { public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) { final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json") - .replace("%%HEARING_ID%%", hearingId) .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString()) .replace("%%START_DATE%%", date.toString()); @@ -61,15 +60,15 @@ public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final } public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) { - final String payload = "{\"hearingId\":\"" + hearingId + "\",\"startDate\":\"" + date + "\"}"; + final String payload = "{\"startDate\":\"" + date + "\"}"; return postMove(hearingId, payload); } /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this - * steps' own courtCentreId so only the hearingId lookup is exercised. */ + * steps' own courtCentreId so only the hearingId lookup is exercised. The target hearing is + * identified purely by the URL path - hearingId is not part of the body. */ public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) { - final String payload = "{\"hearingId\":\"" + otherHearingId + "\",\"courtCentreId\":\"" + courtCentreId - + "\",\"startDate\":\"" + date + "\"}"; + final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startDate\":\"" + date + "\"}"; return postMove(otherHearingId.toString(), payload); } diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java index 0da555232..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 @@ -1456,13 +1456,13 @@ public static void stubMoveHearingToPastDate(final String hearingId, stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(OK.getStatusCode()) .withBody(body) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Stub a courtscheduler rejection (422 FUTURE_DATE_NOT_ALLOWED or 404 no-session) for move-hearing-to-past-date. */ + /** Stub a courtscheduler rejection (e.g. 422 FUTURE_DATE_NOT_ALLOWED / NO_SESSION_FOUND, or a + * legacy 404 no-session) for move-hearing-to-past-date. */ public static void stubMoveHearingToPastDateFailure(final String hearingId, final int statusCode, final String errorCode, @@ -1475,19 +1475,18 @@ public static void stubMoveHearingToPastDateFailure(final String hearingId, stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))) .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\"")) .willReturn(aResponse().withStatus(statusCode) .withBody(body.toString()) .withHeader(CONTENT_TYPE, APPLICATION_JSON))); } - /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing. */ + /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing + * (matched on the URL path - hearingId no longer travels in the request body). */ public static void verifyMoveHearingToPastDateCalled(final String hearingId) { Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> { try { WireMock.verify(WireMock.postRequestedFor(urlPathMatching( - COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)) - .withRequestBody(containing("\"hearingId\":\"" + hearingId + "\""))); + COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId))); return true; } catch (VerificationException e) { return false; diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json index 4bc623ddd..ab58d1d4f 100644 --- a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json +++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "%%HEARING_ID%%", "courtCentreId": "%%COURT_CENTRE_ID%%", "startDate": "%%START_DATE%%" } diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json index 4bc623ddd..ab58d1d4f 100644 --- a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json +++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json @@ -1,5 +1,4 @@ { - "hearingId": "%%HEARING_ID%%", "courtCentreId": "%%COURT_CENTRE_ID%%", "startDate": "%%START_DATE%%" } From bd340c4154af3187a5a99343e28d874f1e5520af Mon Sep 17 00:00:00 2001 From: Baris Ozturkmen Date: Fri, 3 Jul 2026 11:34:12 +0100 Subject: [PATCH 10/10] fix(SPRDT-987): authorise move-hearing-to-past-date by the same groups as update-hearing-for-listing - drops the unseeded 'Change hearing to past date'/Link permission (PermissionConstants removed, move IT uses the standard group stub) --- .../accesscontrol/PermissionConstants.java | 32 ------------------- .../api/accesscontrol/listing-command-api.drl | 3 +- .../command/api/ListingAccessControlTest.java | 10 +++--- .../PermissionConstantsTest.java | 23 ------------- .../steps/MoveHearingToPastDateSteps.java | 2 -- .../cpp/listing/utils/WireMockStubUtils.java | 21 ------------ 6 files changed, 6 insertions(+), 85 deletions(-) delete mode 100644 listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java delete mode 100644 listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java deleted file mode 100644 index 97bb9ea37..000000000 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstants.java +++ /dev/null @@ -1,32 +0,0 @@ -package uk.gov.moj.cpp.listing.command.api.accesscontrol; - -import static uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission.builder; - -import uk.gov.justice.services.common.converter.jackson.ObjectMapperProducer; -import uk.gov.moj.cpp.accesscontrol.drools.ExpectedPermission; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -@JsonPropertyOrder({"object", "action", "key", "keyWithOutSource"}) -public final class PermissionConstants { - - private static final ObjectMapper objectMapper = new ObjectMapperProducer().objectMapper(); - - private static final String CHANGE_HEARING_TO_PAST_DATE_OBJECT = "Change hearing to past date"; - private static final String LINK_ACTION = "Link"; - - private PermissionConstants() { - } - - public static String createChangeHearingToPastDatePermission() throws JsonProcessingException { - final ExpectedPermission expectedPermission = builder() - .withObject(CHANGE_HEARING_TO_PAST_DATE_OBJECT) - .withAction(LINK_ACTION) - .build(); - - return objectMapper.writeValueAsString(expectedPermission); - } - -} diff --git a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl index 454408627..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 @@ -3,7 +3,6 @@ package uk.gov.moj.cpp.listing.command.api.accesscontrol; import uk.gov.moj.cpp.accesscontrol.drools.Outcome; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.listing.domain.RuleConstants; -import uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants; import java.util.Arrays; global uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider userAndGroupProvider; @@ -244,7 +243,7 @@ rule "Command - API - Action - listing.command.move-hearing-to-past-date" when $outcome: Outcome(); $action: Action(name == "listing.command.move-hearing-to-past-date"); - eval(userAndGroupProvider.hasPermission($action, PermissionConstants.createChangeHearingToPastDatePermission())); + eval(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups($action, RuleConstants.LISTING_OFFICERS, RuleConstants.CROWN_COURT_ADMIN, RuleConstants.COURT_ADMINISTRATORS, RuleConstants.COURT_CLERKS, RuleConstants.LEGAL_ADVISERS, RuleConstants.COURT_ASSOCIATE)); then $outcome.setSuccess(true); end diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java index 9ae1c6fa4..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 @@ -2,7 +2,6 @@ import static java.util.Collections.singletonMap; import static org.mockito.BDDMockito.given; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ADMINISTRATORS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_ASSOCIATE; import static uk.gov.moj.cpp.listing.domain.RuleConstants.COURT_CLERKS; @@ -18,7 +17,6 @@ import static uk.gov.moj.cpp.listing.domain.RuleConstants.SYSTEM_USERS; import static uk.gov.moj.cpp.listing.domain.RuleConstants.YOTS; -import com.fasterxml.jackson.core.JsonProcessingException; import uk.gov.moj.cpp.accesscontrol.common.providers.UserAndGroupProvider; import uk.gov.moj.cpp.accesscontrol.drools.Action; import uk.gov.moj.cpp.accesscontrol.test.utils.BaseDroolsAccessControlTest; @@ -312,9 +310,11 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() { } @Test - public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToPastDate() throws JsonProcessingException { + public void shouldAllowAuthorisedUserToMoveHearingToPastDate() { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); - given(userAndGroupProvider.hasPermission(action, createChangeHearingToPastDatePermission())).willReturn(true); + given(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups(action, LISTING_OFFICERS, + CROWN_COURT_ADMIN, COURT_ADMINISTRATORS, COURT_CLERKS, LEGAL_ADVISERS, COURT_ASSOCIATE)) + .willReturn(true); final ExecutionResults results = executeRulesWith(action); @@ -322,7 +322,7 @@ public void shouldAllowUserWithChangeHearingToPastDatePermissionToMoveHearingToP } @Test - public void shouldNotAllowUserWithoutChangeHearingToPastDatePermissionToMoveHearingToPastDate() { + public void shouldNotAllowUnauthorisedUserToMoveHearingToPastDate() { final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE); final ExecutionResults results = executeRulesWith(action); diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java deleted file mode 100644 index 250c9ae4a..000000000 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/accesscontrol/PermissionConstantsTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package uk.gov.moj.cpp.listing.command.api.accesscontrol; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static uk.gov.moj.cpp.listing.command.api.accesscontrol.PermissionConstants.createChangeHearingToPastDatePermission; -import static uk.gov.moj.cpp.listing.command.api.util.FileUtil.getPayload; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -class PermissionConstantsTest { - - private final ObjectMapper mapper = new ObjectMapper(); - - @Test - void shouldCreateChangeHearingToPastDatePermission() throws JsonProcessingException { - JsonNode actual = mapper.readTree(createChangeHearingToPastDatePermission()); - JsonNode expected = mapper.readTree(getPayload("create-change-hearing-to-past-date-permission.json")); - assertThat(actual, is(expected)); - } -} diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java index bb1a81efc..4751a6baf 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java @@ -13,7 +13,6 @@ import static uk.gov.moj.cpp.listing.utils.FileUtil.getPayload; import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri; import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig; -import static uk.gov.moj.cpp.listing.utils.WireMockStubUtils.setupLoggedInUserPermissionsWithChangeHearingToPastDate; import uk.gov.moj.cpp.listing.it.AbstractIT; import uk.gov.moj.cpp.listing.steps.data.HearingData; @@ -44,7 +43,6 @@ public MoveHearingToPastDateSteps(final HearingsData hearingsData) { this.hearingId = hearingData.getId().toString(); this.courtCentreId = hearingData.getCourtCentreId(); givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE); - setupLoggedInUserPermissionsWithChangeHearingToPastDate(); } public String getHearingId() { diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java index 631025dcf..3668ccd21 100644 --- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java +++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/WireMockStubUtils.java @@ -62,27 +62,6 @@ public static void setupUsersGroupPermissionsForApplicationTypeStub() { } - /** - * Serves {@code UserAndGroupProvider.getUserPermissionsByUserId} (the {@code hasPermission} DRL path, - * Accept {@code application/vnd.usersgroups.get-logged-in-user-permissions+json}) so the - * "Change hearing to past date"/"Link" permission checked by the move-hearing-to-past-date rule - * resolves to true. Matches on URL alone — the empty-permissions catch-all - * ({@link #setupUsersGroupPermissionsForApplicationTypeStub()}) only matches the - * is-logged-in-user-has-permission-for-action Accept header, so without this stub the permissions - * query gets no match and the provider receives a NULL payload. - */ - public static void setupLoggedInUserPermissionsWithChangeHearingToPastDate() { - stubFor(get(urlMatching("/usersgroups-service/query/api/rest/usersgroups/users/logged-in-user/permissions.*")) - .atPriority(1) - .willReturn(aResponse().withStatus(OK.getStatusCode()) - .withHeader("CPPID", randomUUID().toString()) - .withHeader("Content-Type", "application/json") - .withBody(String.valueOf(createObjectBuilder() - .add("permissions", createArrayBuilder() - .add(createObjectBuilder().add("object", "Change hearing to past date").add("action", "Link"))) - .build())))); - } - public static void setupAsUnauthorisedUser(final UUID userId) { stubPingFor("usersgroups-service");