Skip to content

Commit b16733b

Browse files
HMAI -237 Cancelled - POST /v1/visit/{visitReference}/cancel (#765)
* add CancelVisitRequest model and sendCancelVisit to VisitQueueService * service tests * add more tests for queue service and controller logic * add controller tests for cancel visit * remove prisonerId from cancelVisitRequest; adjust service to use getVisitInformationByReference service * use visitReference parameter from url instead of request body * add integration tests --------- Co-authored-by: BushraAbdullahi <bushra.abdullahi@digital.justice.gov.uk>
1 parent 52afe60 commit b16733b

File tree

10 files changed

+409
-2
lines changed

10 files changed

+409
-2
lines changed

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/controllers/v1/VisitsController.kt

+61-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestBody
1616
import org.springframework.web.bind.annotation.RequestMapping
1717
import org.springframework.web.bind.annotation.RestController
1818
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelVisitRequest
1920
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CreateVisitRequest
2021
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.DataResponse
2122
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.HmppsMessageResponse
@@ -131,7 +132,66 @@ class VisitsController(
131132
throw ValidationException("Either invalid prisoner or prison id.")
132133
}
133134

134-
auditService.createEvent("POST_VISIT", mapOf())
135+
auditService.createEvent("POST_VISIT", mapOf("prisonerId" to createVisitRequest.prisonerId, "clientVisitReference" to createVisitRequest.clientVisitReference, "clientName" to clientName.orEmpty()))
136+
137+
return DataResponse(response.data)
138+
}
139+
140+
@Operation(
141+
summary = "Cancel visit.",
142+
description = "<br><br><b>Applicable filters</b>: <ul><li>prisons</li></ul>",
143+
responses = [
144+
ApiResponse(responseCode = "200", useReturnTypeSchema = true, description = "Successfully wrote to visit queue."),
145+
ApiResponse(
146+
responseCode = "400",
147+
content = [
148+
Content(
149+
schema =
150+
io.swagger.v3.oas.annotations.media
151+
.Schema(ref = "#/components/schemas/BadRequest"),
152+
),
153+
],
154+
),
155+
ApiResponse(
156+
responseCode = "404",
157+
content = [
158+
Content(
159+
schema =
160+
io.swagger.v3.oas.annotations.media
161+
.Schema(ref = "#/components/schemas/PersonNotFound"),
162+
),
163+
],
164+
),
165+
ApiResponse(
166+
responseCode = "500",
167+
content = [
168+
Content(
169+
schema =
170+
io.swagger.v3.oas.annotations.media
171+
.Schema(ref = "#/components/schemas/InternalServerError"),
172+
),
173+
],
174+
),
175+
],
176+
)
177+
@PostMapping("/{visitReference}/cancel")
178+
fun postCancelVisit(
179+
@Valid @RequestBody cancelVisitRequest: CancelVisitRequest,
180+
@Parameter(description = "The visit reference number relating to the visit.") @PathVariable visitReference: String,
181+
@RequestAttribute clientName: String?,
182+
@RequestAttribute filters: ConsumerFilters?,
183+
): DataResponse<HmppsMessageResponse?> {
184+
val response = visitQueueService.sendCancelVisit(visitReference, cancelVisitRequest, clientName.orEmpty(), filters)
185+
186+
if (response.hasError(UpstreamApiError.Type.ENTITY_NOT_FOUND)) {
187+
throw EntityNotFoundException("Could not find prisoner")
188+
}
189+
190+
if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
191+
throw ValidationException("Either invalid prisoner or prison id.")
192+
}
193+
194+
auditService.createEvent("POST_CANCEL_VISIT", mapOf("visitReference" to visitReference, "clientName" to clientName.orEmpty()))
135195

136196
return DataResponse(response.data)
137197
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
import io.swagger.v3.oas.annotations.media.Schema
4+
5+
@Schema(description = "Private prison visit cancellation request")
6+
data class CancelVisitRequest(
7+
@Schema(description = "Outcome status and description", required = true)
8+
val cancelOutcome: CancelOutcome,
9+
@Schema(description = "Username for user who actioned this request", required = false)
10+
val actionedBy: String?,
11+
) {
12+
fun toHmppsMessage(who: String): HmppsMessage =
13+
HmppsMessage(
14+
eventType = HmppsMessageEventType.VISIT_CANCELLED,
15+
messageAttributes = modelToMap(),
16+
who = who,
17+
)
18+
19+
private fun modelToMap(): Map<String, Any?> =
20+
mapOf(
21+
"cancelOutcome" to this.cancelOutcome,
22+
"actionedBy" to this.actionedBy,
23+
)
24+
}
25+
26+
data class CancelOutcome(
27+
val outcomeStatus: OutcomeStatus,
28+
val text: String,
29+
)
30+
31+
enum class OutcomeStatus {
32+
ADMINISTRATIVE_CANCELLATION,
33+
ADMINISTRATIVE_ERROR,
34+
BATCH_CANCELLATION,
35+
CANCELLATION,
36+
COMPLETED_NORMALLY,
37+
ESTABLISHMENT_CANCELLED,
38+
NOT_RECORDED,
39+
NO_VISITING_ORDER,
40+
PRISONER_CANCELLED,
41+
PRISONER_COMPLETED_EARLY,
42+
PRISONER_REFUSED_TO_ATTEND,
43+
TERMINATED_BY_STAFF,
44+
VISITOR_CANCELLED,
45+
VISITOR_COMPLETED_EARLY,
46+
VISITOR_DECLINED_ENTRY,
47+
VISITOR_DID_NOT_ARRIVE,
48+
VISITOR_FAILED_SECURITY_CHECKS,
49+
VISIT_ORDER_CANCELLED,
50+
SUPERSEDED_CANCELLATION,
51+
DETAILS_CHANGED_AFTER_BOOKING,
52+
BOOKER_CANCELLED,
53+
}

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/services/VisitQueueService.kt

+34
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired
55
import org.springframework.stereotype.Service
66
import software.amazon.awssdk.services.sqs.model.SendMessageRequest
77
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.MessageFailedException
8+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelVisitRequest
89
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CreateVisitRequest
910
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.HmppsMessageResponse
1011
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
@@ -18,6 +19,7 @@ class VisitQueueService(
1819
@Autowired private val getPersonService: GetPersonService,
1920
@Autowired private val hmppsQueueService: HmppsQueueService,
2021
@Autowired private val objectMapper: ObjectMapper,
22+
@Autowired private val getVisitInformationByReferenceService: GetVisitInformationByReferenceService,
2123
) {
2224
private val visitsQueue by lazy { hmppsQueueService.findByQueueId("visits") as HmppsQueue }
2325
private val visitsQueueSqsClient by lazy { visitsQueue.sqsClient }
@@ -54,4 +56,36 @@ class VisitQueueService(
5456
throw MessageFailedException("Could not send Visit message to queue", e)
5557
}
5658
}
59+
60+
fun sendCancelVisit(
61+
visitReference: String,
62+
visit: CancelVisitRequest,
63+
who: String,
64+
consumerFilters: ConsumerFilters?,
65+
): Response<HmppsMessageResponse?> {
66+
val visitResponse = getVisitInformationByReferenceService.execute(visitReference, consumerFilters)
67+
68+
if (visitResponse.errors.isNotEmpty()) {
69+
return Response(data = null, errors = visitResponse.errors)
70+
}
71+
72+
val hmppsMessage = visit.toHmppsMessage(who)
73+
74+
try {
75+
val stringifiedMessage = objectMapper.writeValueAsString(hmppsMessage)
76+
val sendMessageRequest =
77+
SendMessageRequest
78+
.builder()
79+
.queueUrl(visitsQueueUrl)
80+
.messageBody(stringifiedMessage)
81+
.eventTypeMessageAttributes(hmppsMessage.eventType.toString())
82+
.build()
83+
84+
visitsQueueSqsClient.sendMessage(sendMessageRequest)
85+
86+
return Response(HmppsMessageResponse(message = "Visit cancellation written to queue"))
87+
} catch (e: Exception) {
88+
throw MessageFailedException("Could not send Visit cancellation to queue", e)
89+
}
90+
}
5791
}

src/main/resources/application-integration-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ authorisation:
112112
- "/v1/persons/.*/visitor/.*/restrictions"
113113
- "/v1/visit/[^/]*$"
114114
- "/v1/visit"
115+
- "/v1/visit/.*/cancel"
115116
- "/v1/prison/.*/visit/search[^/]*$"
116117
- "/v1/contacts/[^/]*$"
117118
- "/v1/persons/.*/contacts[^/]*$"

src/main/resources/application-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ authorisation:
115115
- "/v1/persons/.*/visitor/.*/restrictions"
116116
- "/v1/visit/[^/]*$"
117117
- "/v1/visit"
118+
- "/v1/visit/.*/cancel"
118119
- "/v1/prison/.*/visit/search[^/]*$"
119120
- "/v1/contacts/[^/]*$"
120121
- "/v1/persons/.*/contacts[^/]*$"

src/main/resources/globals.yml

+1
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ globals:
3636
- "/v1/prison/.*/visit/search[^/]*$"
3737
- "/v1/visit/[^/]*$"
3838
- "/v1/visit"
39+
- "/v1/visit/.*/cancel"
3940
- "/v1/contacts/[^/]*$"

src/test/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/controllers/v1/VisitsControllerTest.kt

+72-1
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import org.springframework.test.web.servlet.MockMvc
1515
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.MessageFailedException
1616
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.extensions.removeWhitespaceAndNewlines
1717
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.helpers.IntegrationAPIMockMvc
18+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelOutcome
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelVisitRequest
1820
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CreateVisitRequest
1921
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.HmppsMessageResponse
22+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.OutcomeStatus
2023
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
2124
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
2225
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
@@ -173,7 +176,7 @@ class VisitsControllerTest(
173176
verify(
174177
auditService,
175178
times(1),
176-
).createEvent("POST_VISIT", mapOf())
179+
).createEvent("POST_VISIT", mapOf("prisonerId" to createVisitRequest.prisonerId, "clientVisitReference" to createVisitRequest.clientVisitReference, "clientName" to clientName))
177180
}
178181

179182
it("Calls the visit queue service and gets a response") {
@@ -211,5 +214,73 @@ class VisitsControllerTest(
211214
result.response.status.shouldBe(HttpStatus.INTERNAL_SERVER_ERROR.value())
212215
}
213216
}
217+
218+
describe("/v1/visit/{visitReference}/cancel") {
219+
val visitReference = "1234567"
220+
val path = "/v1/visit/$visitReference/cancel"
221+
val filters = null
222+
val message = "Visit Message"
223+
val postResponse = HmppsMessageResponse(message = message)
224+
val clientName = "automated-test-client"
225+
val cancelVisitRequest =
226+
CancelVisitRequest(
227+
cancelOutcome =
228+
CancelOutcome(
229+
outcomeStatus = OutcomeStatus.VISIT_ORDER_CANCELLED,
230+
text = "Visitor has informed us they cannot make the visit.",
231+
),
232+
actionedBy = "someUser",
233+
)
234+
235+
beforeTest {
236+
Mockito.reset(visitQueueService)
237+
238+
whenever(visitQueueService.sendCancelVisit(visitReference, cancelVisitRequest, clientName, filters)).thenReturn(Response(data = postResponse))
239+
}
240+
241+
it("logs audit") {
242+
mockMvc.performAuthorisedPost(path, cancelVisitRequest)
243+
244+
verify(
245+
auditService,
246+
times(1),
247+
).createEvent("POST_CANCEL_VISIT", mapOf("visitReference" to visitReference, "clientName" to clientName))
248+
}
249+
250+
it("Calls the visit queue service and gets a response") {
251+
val result = mockMvc.performAuthorisedPost(path, cancelVisitRequest)
252+
result.response.status.shouldBe(HttpStatus.OK.value())
253+
result.response.contentAsString shouldBe (
254+
"""
255+
{
256+
"data": {
257+
"message": "$message"
258+
}
259+
}
260+
""".removeWhitespaceAndNewlines()
261+
)
262+
}
263+
264+
it("returns a 400 when upstream returns 400") {
265+
whenever(visitQueueService.sendCancelVisit(visitReference, cancelVisitRequest, clientName, filters)).thenReturn(Response(data = null, errors = listOf(UpstreamApiError(causedBy = UpstreamApi.MANAGE_PRISON_VISITS, type = UpstreamApiError.Type.BAD_REQUEST))))
266+
267+
val result = mockMvc.performAuthorisedPost(path, cancelVisitRequest)
268+
result.response.status.shouldBe(HttpStatus.BAD_REQUEST.value())
269+
}
270+
271+
it("returns a 404 when upstream returns 404") {
272+
whenever(visitQueueService.sendCancelVisit(visitReference, cancelVisitRequest, clientName, filters)).thenReturn(Response(data = null, errors = listOf(UpstreamApiError(causedBy = UpstreamApi.MANAGE_PRISON_VISITS, type = UpstreamApiError.Type.ENTITY_NOT_FOUND))))
273+
274+
val result = mockMvc.performAuthorisedPost(path, cancelVisitRequest)
275+
result.response.status.shouldBe(HttpStatus.NOT_FOUND.value())
276+
}
277+
278+
it("gets a 500 when visit queue service throws MessageFailedException") {
279+
whenever(visitQueueService.sendCancelVisit(visitReference, cancelVisitRequest, clientName, filters)).thenThrow(MessageFailedException("Could not send Visit message to queue"))
280+
281+
val result = mockMvc.performAuthorisedPost(path, cancelVisitRequest)
282+
result.response.status.shouldBe(HttpStatus.INTERNAL_SERVER_ERROR.value())
283+
}
284+
}
214285
},
215286
)

src/test/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/integration/VisitsIntegrationTest.kt

+66
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
1313
import software.amazon.awssdk.services.sqs.model.Message
1414
import software.amazon.awssdk.services.sqs.model.PurgeQueueRequest
1515
import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest
16+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelOutcome
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CancelVisitRequest
1618
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.CreateVisitRequest
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.OutcomeStatus
1720
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.VisitRestriction
1821
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.VisitStatus
1922
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.VisitType
@@ -169,4 +172,67 @@ class VisitsIntegrationTest : IntegrationTestBase() {
169172
.andExpect(status().isNotFound)
170173
}
171174
}
175+
176+
@DisplayName("POST /v1/visit/{visitReference}/cancel")
177+
@Nested
178+
inner class PostCancelVisit {
179+
private val visitReference = "123456"
180+
private val path = "/v1/visit/$visitReference/cancel"
181+
private val cancelVisitRequest =
182+
CancelVisitRequest(
183+
cancelOutcome =
184+
CancelOutcome(
185+
outcomeStatus = OutcomeStatus.VISIT_ORDER_CANCELLED,
186+
text = "visit order cancelled",
187+
),
188+
actionedBy = "test-consumer",
189+
)
190+
191+
@Test
192+
fun `post the visit cancellation, get back a message response and find a message on the queue`() {
193+
val requestBody = asJsonString(cancelVisitRequest)
194+
195+
postToApi(path, requestBody)
196+
.andExpect(status().isOk)
197+
.andExpect(
198+
content().json(
199+
"""
200+
{
201+
"data": {
202+
"message": "Visit cancellation written to queue"
203+
}
204+
}
205+
""",
206+
),
207+
)
208+
209+
val queueMessages = getQueueMessages()
210+
queueMessages.size.shouldBe(1)
211+
212+
val messageJson = queueMessages[0].body()
213+
val expectedMessage = cancelVisitRequest.toHmppsMessage(defaultCn)
214+
messageJson.shouldContainJsonKeyValue("$.eventType", expectedMessage.eventType.eventTypeCode)
215+
messageJson.shouldContainJsonKeyValue("$.who", defaultCn)
216+
val objectMapper = jacksonObjectMapper()
217+
val messageAttributes = objectMapper.readTree(messageJson).at("/messageAttributes")
218+
val expectedMessageAttributes = objectMapper.readTree(objectMapper.writeValueAsString(expectedMessage.messageAttributes))
219+
messageAttributes.shouldBe(expectedMessageAttributes)
220+
}
221+
222+
@Test
223+
fun `return a 404 when prison not in filter`() {
224+
val requestBody = asJsonString(cancelVisitRequest)
225+
226+
postToApiWithCN(path, requestBody, limitedPrisonsCn)
227+
.andExpect(status().isNotFound)
228+
}
229+
230+
@Test
231+
fun `return a 404 when no prisons in filter`() {
232+
val requestBody = asJsonString(cancelVisitRequest)
233+
234+
postToApiWithCN(path, requestBody, noPrisonsCn)
235+
.andExpect(status().isNotFound)
236+
}
237+
}
172238
}

0 commit comments

Comments
 (0)