Skip to content

Commit

Permalink
Man 110 backend double appointment check (#4454)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
1 parent 0a3fbd7 commit 6560222
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import java.util.*

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CreateAppointmentIntegrationTests {
class CreateAppointmentIntegrationTest {

@Autowired
internal lateinit var mockMvc: MockMvc
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -165,15 +164,15 @@ 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()
),
CreateAppointment(
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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,3 +44,8 @@ data class User(
val username: String,
val team: String
)

data class OverlappingAppointment(
val start: String,
val end: String,
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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<CreateAppointment> = arrayListOf()
Expand All @@ -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
Expand All @@ -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) }
Expand All @@ -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")
}
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -50,6 +55,9 @@ class SentenceAppointmentServiceTest {
@Mock
lateinit var staffUserRepository: StaffUserRepository

@Mock
lateinit var objectMapper: ObjectMapper

@InjectMocks
lateinit var service: SentenceAppointmentService

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ConflictException> { 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
}
}

0 comments on commit 6560222

Please sign in to comment.