From 656022227dfcc65c3a8ab0837c6e82c72e7a8559 Mon Sep 17 00:00:00 2001 From: achimber-moj <161360519+achimber-moj@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:28:35 +0000 Subject: [PATCH] Man 110 backend double appointment check (#4454) * MAN-110 - make end date a mandatory parameter and update tests * MAN-110 - update logic and test * MAN-110 - update logic and test * MAN-110 - update logic to allow the creation of appointments that overlap * Formatting changes * MAN-110 - identify test failure when pipeline is run * MAN-110 - rename test * MAN-110 - add new test * Formatting changes * MAN-110 - change ZoneId as test is failing in pipeline * MAN-110 - remove failing pipeline test * MAN-110 - add new service test * Formatting changes --------- Co-authored-by: probation-integration-bot[bot] <177347787+probation-integration-bot[bot]@users.noreply.github.com> --- ...kt => CreateAppointmentIntegrationTest.kt} | 14 ++- .../model/appointment/CreateAppointment.kt | 10 +- .../service/SentenceAppointmentService.kt | 53 ++++++--- .../service/SentenceAppointmentServiceTest.kt | 106 +++++++++++++++++- 4 files changed, 157 insertions(+), 26 deletions(-) rename projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/{CreateAppointmentIntegrationTests.kt => CreateAppointmentIntegrationTest.kt} (95%) diff --git a/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt b/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTest.kt similarity index 95% rename from projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt rename to projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTest.kt index 4a4e2f2020..5462baad0f 100644 --- a/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt +++ b/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTest.kt @@ -33,7 +33,7 @@ import java.util.* @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class CreateAppointmentIntegrationTests { +class CreateAppointmentIntegrationTest { @Autowired internal lateinit var mockMvc: MockMvc @@ -43,6 +43,8 @@ class CreateAppointmentIntegrationTests { private val user = User(STAFF_USER_1.username, TEAM.description) + private val person = PersonGenerator.PERSON_1 + @Test fun `unauthorized status returned`() { mockMvc @@ -95,8 +97,6 @@ class CreateAppointmentIntegrationTests { @ParameterizedTest @MethodSource("createAppointment") fun `create a new appointment`(createAppointment: CreateAppointment) { - val person = PersonGenerator.PERSON_1 - val response = mockMvc.perform( post("/appointment/${person.crn}") .withToken() @@ -124,7 +124,6 @@ class CreateAppointmentIntegrationTests { @ParameterizedTest @MethodSource("createMultipleAppointments") fun `create multiple appointments`(createAppointment: CreateAppointment) { - val person = PersonGenerator.PERSON_1 val response = mockMvc.perform( post("/appointment/${person.crn}") .withToken() @@ -165,7 +164,7 @@ class CreateAppointmentIntegrationTests { user, CreateAppointment.Type.PlannedOfficeVisitNS, ZonedDateTime.now().plusDays(1), - ZonedDateTime.now().plusDays(2), + ZonedDateTime.now().plusDays(1).plusHours(1), eventId = PersonGenerator.EVENT_1.id, uuid = UUID.randomUUID() ), @@ -173,7 +172,7 @@ class CreateAppointmentIntegrationTests { user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), - null, + ZonedDateTime.now().plusDays(1).plusHours(1), CreateAppointment.Interval.DAY, eventId = PersonGenerator.EVENT_1.id, uuid = UUID.randomUUID() @@ -186,6 +185,7 @@ class CreateAppointmentIntegrationTests { user, CreateAppointment.Type.HomeVisitToCaseNS, ZonedDateTime.now(), + ZonedDateTime.now().plusHours(1), numberOfAppointments = 3, eventId = PersonGenerator.EVENT_1.id, uuid = UUID.randomUUID() @@ -194,6 +194,7 @@ class CreateAppointmentIntegrationTests { user, CreateAppointment.Type.HomeVisitToCaseNS, ZonedDateTime.now(), + end = ZonedDateTime.now().plusHours(1), until = ZonedDateTime.now().plusDays(2), eventId = PersonGenerator.EVENT_1.id, uuid = UUID.randomUUID() @@ -202,6 +203,7 @@ class CreateAppointmentIntegrationTests { user, CreateAppointment.Type.HomeVisitToCaseNS, start = ZonedDateTime.now(), + end = ZonedDateTime.now().plusHours(2), until = ZonedDateTime.now().plusDays(14), interval = CreateAppointment.Interval.WEEK, eventId = PersonGenerator.EVENT_1.id, diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt index 598186fe0b..059fad9c00 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt @@ -8,14 +8,15 @@ data class CreateAppointment( val user: User, val type: Type, val start: ZonedDateTime, - val end: ZonedDateTime? = null, + val end: ZonedDateTime, val interval: Interval = Interval.DAY, val numberOfAppointments: Int = 1, val eventId: Long, val uuid: UUID, + val createOverlappingAppointment: Boolean = false, val requirementId: Long? = null, val licenceConditionId: Long? = null, - val until: ZonedDateTime? = null + val until: ZonedDateTime? = null, ) { @JsonIgnore val urn = URN_PREFIX + uuid @@ -43,3 +44,8 @@ data class User( val username: String, val team: String ) + +data class OverlappingAppointment( + val start: String, + val end: String, +) diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt index 043c8b13cf..55ca69c023 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt @@ -1,11 +1,14 @@ package uk.gov.justice.digital.hmpps.service +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.stereotype.Service import uk.gov.justice.digital.hmpps.api.model.appointment.AppointmentDetail import uk.gov.justice.digital.hmpps.api.model.appointment.CreateAppointment import uk.gov.justice.digital.hmpps.api.model.appointment.CreatedAppointment +import uk.gov.justice.digital.hmpps.api.model.appointment.OverlappingAppointment import uk.gov.justice.digital.hmpps.audit.service.AuditableService import uk.gov.justice.digital.hmpps.audit.service.AuditedInteractionService +import uk.gov.justice.digital.hmpps.datetime.DeliusDateTimeFormatter import uk.gov.justice.digital.hmpps.datetime.EuropeLondon import uk.gov.justice.digital.hmpps.exception.ConflictException import uk.gov.justice.digital.hmpps.exception.InvalidRequestException @@ -27,7 +30,8 @@ class SentenceAppointmentService( private val eventSentenceRepository: EventSentenceRepository, private val requirementRepository: RequirementRepository, private val licenceConditionRepository: LicenceConditionRepository, - private val staffUserRepository: StaffUserRepository + private val staffUserRepository: StaffUserRepository, + private val objectMapper: ObjectMapper ) : AuditableService(auditedInteractionService) { fun createAppointment( crn: String, @@ -36,7 +40,9 @@ class SentenceAppointmentService( return audit(BusinessInteractionCode.ADD_CONTACT) { audit -> val om = offenderManagerRepository.getByCrn(crn) audit["offenderId"] = om.person.id - checkForConflicts(om.person.id, createAppointment) + + checkForConflicts(createAppointment) + val userAndLocation = staffUserRepository.getUserAndLocation(createAppointment.user.username, createAppointment.user.team) val createAppointments: ArrayList = arrayListOf() @@ -58,11 +64,12 @@ class SentenceAppointmentService( createAppointment.user, createAppointment.type, createAppointment.start.plusDays(interval.toLong()), - createAppointment.end?.plusDays(interval.toLong()), + createAppointment.end.plusDays(interval.toLong()), createAppointment.interval, createAppointment.numberOfAppointments, createAppointment.eventId, if (i == 0) createAppointment.uuid else UUID.randomUUID(), //needs to be a unique value + createAppointment.createOverlappingAppointment, createAppointment.requirementId, createAppointment.licenceConditionId, createAppointment.until @@ -71,6 +78,31 @@ class SentenceAppointmentService( } } + val overlappingAppointments = createAppointments.mapNotNull { + if (it.start.isAfter(ZonedDateTime.now()) && appointmentRepository.appointmentClashes( + om.person.id, + it.start.toLocalDate(), + it.start, + it.end + ) + ) { + OverlappingAppointment( + it.start.toLocalDateTime().format(DeliusDateTimeFormatter).dropLast(3), + it.end.toLocalDateTime().format(DeliusDateTimeFormatter).dropLast(3) + ) + } else null + } + + if (!createAppointment.createOverlappingAppointment && overlappingAppointments.isNotEmpty()) { + throw ConflictException( + "Appointment(s) conflicts with an existing future appointment ${ + objectMapper.writeValueAsString( + overlappingAppointments + ) + }" + ) + } + val appointments = createAppointments.map { it.withManager(om, userAndLocation) } val savedAppointments = appointmentRepository.saveAll(appointments) val createdAppointments = savedAppointments.map { CreatedAppointment(it.id) } @@ -81,14 +113,13 @@ class SentenceAppointmentService( } private fun checkForConflicts( - personId: Long, createAppointment: CreateAppointment ) { if (createAppointment.requirementId != null && createAppointment.licenceConditionId != null) { throw InvalidRequestException("Either licence id or requirement id can be provided, not both") } - createAppointment.end?.let { + createAppointment.end.let { if (it.isBefore(createAppointment.start)) throw InvalidRequestException("Appointment end time cannot be before start time") } @@ -110,16 +141,6 @@ class SentenceAppointmentService( throw NotFoundException("LicenceCondition", "licenceConditionId", createAppointment.licenceConditionId) } - if (createAppointment.start.isAfter(ZonedDateTime.now()) && appointmentRepository.appointmentClashes( - personId, - createAppointment.start.toLocalDate(), - createAppointment.start, - createAppointment.start - ) - ) { - throw ConflictException("Appointment conflicts with an existing future appointment") - } - val licenceOrRequirement = listOfNotNull(createAppointment.licenceConditionId, createAppointment.requirementId) if (licenceOrRequirement.size > 1) { @@ -135,7 +156,7 @@ class SentenceAppointmentService( teamId = userAndLocation.teamId, staffId = userAndLocation.staffId, 0, - end?.let { ZonedDateTime.of(LocalDate.EPOCH, end.toLocalTime(), EuropeLondon) }, + end.let { ZonedDateTime.of(LocalDate.EPOCH, end.toLocalTime(), EuropeLondon) }, probationAreaId = userAndLocation.providerId, urn, eventId = eventId, diff --git a/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt b/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt index 0b253edf5b..0a5d0e1a10 100644 --- a/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt +++ b/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt @@ -1,5 +1,6 @@ package uk.gov.justice.digital.hmpps.service +import com.fasterxml.jackson.databind.ObjectMapper import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Test @@ -16,11 +17,15 @@ import uk.gov.justice.digital.hmpps.api.model.appointment.User import uk.gov.justice.digital.hmpps.audit.service.AuditedInteractionService import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator import uk.gov.justice.digital.hmpps.data.generator.PersonGenerator +import uk.gov.justice.digital.hmpps.exception.ConflictException import uk.gov.justice.digital.hmpps.exception.InvalidRequestException import uk.gov.justice.digital.hmpps.exception.NotFoundException +import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.ContactType import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.RequirementRepository import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.* +import java.time.ZoneId import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.* @ExtendWith(MockitoExtension::class) @@ -50,6 +55,9 @@ class SentenceAppointmentServiceTest { @Mock lateinit var staffUserRepository: StaffUserRepository + @Mock + lateinit var objectMapper: ObjectMapper + @InjectMocks lateinit var service: SentenceAppointmentService @@ -123,11 +131,12 @@ class SentenceAppointmentServiceTest { } @Test - fun `until before end date`() { + fun `until before start date`() { val appointment = CreateAppointment( user, CreateAppointment.Type.InitialAppointmentInOfficeNS, start = ZonedDateTime.now().plusDays(2), + end = ZonedDateTime.now().plusDays(2), until = ZonedDateTime.now().plusDays(1), interval = CreateAppointment.Interval.FORTNIGHT, numberOfAppointments = 3, @@ -158,7 +167,7 @@ class SentenceAppointmentServiceTest { user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), - null, + ZonedDateTime.now().plusDays(1), interval = CreateAppointment.Interval.FOUR_WEEKS, numberOfAppointments = 1, 1, @@ -247,4 +256,97 @@ class SentenceAppointmentServiceTest { verifyNoInteractions(appointmentRepository) verifyNoInteractions(appointmentTypeRepository) } + + @Test + fun `error overlapping appointment`() { + val appointment = CreateAppointment( + user, + CreateAppointment.Type.HomeVisitToCaseNS, + ZonedDateTime.now().plusDays(1), + ZonedDateTime.now().plusDays(2), + numberOfAppointments = 3, + eventId = PersonGenerator.EVENT_1.id, + uuid = uuid + ) + + whenever(offenderManagerRepository.findByPersonCrnAndSoftDeletedIsFalseAndActiveIsTrue(PersonGenerator.PERSON_1.crn)).thenReturn( + OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE + ) + whenever(staffUserRepository.findUserAndLocation(appointment.user.username, appointment.user.team)) + .thenReturn(UserLoc(1, 2, 3, 4, 5)) + + whenever(eventSentenceRepository.existsById(appointment.eventId)).thenReturn(true) + + whenever( + appointmentRepository.getClashCount( + OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE.person.id, + appointment.start.toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), + appointment.start.format(DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneId.systemDefault())), + appointment.end.format(DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneId.systemDefault())) + ) + ).thenReturn(1) + + assertThrows { service.createAppointment(PersonGenerator.PERSON_1.crn, appointment) } + } + + @Test + fun `success create overlapping appointment`() { + val appointment = CreateAppointment( + user, + CreateAppointment.Type.HomeVisitToCaseNS, + ZonedDateTime.now().plusDays(1), + ZonedDateTime.now().plusDays(2), + numberOfAppointments = 3, + eventId = PersonGenerator.EVENT_1.id, + uuid = uuid, + createOverlappingAppointment = true + ) + + whenever(offenderManagerRepository.findByPersonCrnAndSoftDeletedIsFalseAndActiveIsTrue(PersonGenerator.PERSON_1.crn)).thenReturn( + OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE + ) + whenever(staffUserRepository.findUserAndLocation(appointment.user.username, appointment.user.team)) + .thenReturn(UserLoc(1, 2, 3, 4, 5)) + + whenever(eventSentenceRepository.existsById(appointment.eventId)).thenReturn(true) + + whenever(appointmentTypeRepository.findByCode(appointment.type.code)).thenReturn( + ContactType( + 1, + appointment.type.code, + true, + "description" + ) + ) + + whenever( + appointmentRepository.getClashCount( + OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE.person.id, + appointment.start.toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), + appointment.start.format(DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneId.systemDefault())), + appointment.end.format(DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneId.systemDefault())) + ) + ).thenReturn(1) + + service.createAppointment(PersonGenerator.PERSON_1.crn, appointment) + } + + data class UserLoc( + val _userId: Long, + val _staffId: Long, + val _teamId: Long, + val _providerId: Long, + val _locationId: Long, + ) : UserLocation { + override val userId: Long + get() = _userId + override val staffId: Long + get() = _staffId + override val teamId: Long + get() = _teamId + override val providerId: Long + get() = _providerId + override val locationId: Long + get() = _locationId + } } \ No newline at end of file