Skip to content

Commit 10685d4

Browse files
committed
Publish Expression of Interest API
1 parent 36208d1 commit 10685d4

File tree

8 files changed

+320
-1
lines changed

8 files changed

+320
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.controllers.v1.person
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import io.swagger.v3.oas.annotations.Parameter
5+
import io.swagger.v3.oas.annotations.media.Content
6+
import io.swagger.v3.oas.annotations.media.Schema
7+
import io.swagger.v3.oas.annotations.responses.ApiResponse
8+
import io.swagger.v3.oas.annotations.tags.Tag
9+
import org.springframework.beans.factory.annotation.Autowired
10+
import org.springframework.http.ResponseEntity
11+
import org.springframework.web.bind.annotation.PathVariable
12+
import org.springframework.web.bind.annotation.PutMapping
13+
import org.springframework.web.bind.annotation.RequestMapping
14+
import org.springframework.web.bind.annotation.RestController
15+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.expressionOfInterest.ExpressionInterest
16+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
18+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.ExpressionInterestService
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetPersonService
20+
21+
@RestController
22+
@RequestMapping("/v1/persons")
23+
@Tag(name = "persons")
24+
class ExpressionInterestController(
25+
@Autowired val getPersonService: GetPersonService,
26+
val expressionInterestService: ExpressionInterestService,
27+
) {
28+
@PutMapping("{hmppsId}/expression-of-interest/jobs/{jobid}")
29+
@Operation(
30+
summary = "Returns completed response",
31+
responses = [
32+
ApiResponse(responseCode = "200", useReturnTypeSchema = true, description = "Successfully submitted an expression of interest"),
33+
ApiResponse(responseCode = "404", content = [Content(schema = Schema(ref = "#/components/schemas/Error"))]),
34+
ApiResponse(responseCode = "500", content = [Content(schema = Schema(ref = "#/components/schemas/InternalServerError"))]),
35+
],
36+
)
37+
fun submitExpressionOfInterest(
38+
@Parameter(description = "A URL-encoded HMPPS identifier", example = "2008%2F0545166T") @PathVariable hmppsId: String,
39+
@Parameter(description = "A job identifier") @PathVariable jobid: String,
40+
): ResponseEntity<Map<String, String>> {
41+
val hmppsIdResponse = getPersonService.getCombinedDataForPerson(hmppsId)
42+
43+
if (hmppsIdResponse.hasErrorCausedBy(UpstreamApiError.Type.ENTITY_NOT_FOUND, causedBy = UpstreamApi.PROBATION_OFFENDER_SEARCH)) {
44+
return ResponseEntity.badRequest().build()
45+
}
46+
47+
val nomisHmppsId = hmppsIdResponse.data.probationOffenderSearch?.hmppsId
48+
49+
expressionInterestService.sendExpressionOfInterest(ExpressionInterest(jobid, nomisHmppsId))
50+
51+
val responseBody = mapOf("message" to "Expression of interest submitted successfully")
52+
return ResponseEntity.ok(responseBody)
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception
2+
3+
class MessageFailedException(msg: String) : RuntimeException(msg)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.expressionOfInterest
2+
3+
data class ExpressionInterest(
4+
val jobId: String,
5+
val hmppsId: String?,
6+
val eventType: String = "ExpressionOfInterest",
7+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
data class ExpressionOfInterestMessage(
4+
val jobId: String,
5+
val hmppsId: String,
6+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.services
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import org.springframework.stereotype.Component
5+
import org.springframework.stereotype.Service
6+
import software.amazon.awssdk.services.sqs.model.SendMessageRequest
7+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.MessageFailedException
8+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.expressionOfInterest.ExpressionInterest
9+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.ExpressionOfInterestMessage
10+
import uk.gov.justice.hmpps.sqs.HmppsQueue
11+
import uk.gov.justice.hmpps.sqs.HmppsQueueService
12+
13+
@Service
14+
@Component
15+
class ExpressionInterestService(
16+
private val hmppsQueueService: HmppsQueueService,
17+
private val objectMapper: ObjectMapper,
18+
) {
19+
private val eoiQueue by lazy { hmppsQueueService.findByQueueId("eoi-queue") as HmppsQueue }
20+
private val eoiQueueSqsClient by lazy { eoiQueue.sqsClient }
21+
private val eoiQueueUrl by lazy { eoiQueue.queueUrl }
22+
23+
fun sendExpressionOfInterest(expressionInterest: ExpressionInterest) {
24+
if (expressionInterest.hmppsId == null) {
25+
println("HMPPS ID is null; skipping message sending.")
26+
return
27+
}
28+
29+
try {
30+
val messageBody =
31+
objectMapper.writeValueAsString(
32+
ExpressionOfInterestMessage(
33+
jobId = expressionInterest.jobId,
34+
hmppsId = expressionInterest.hmppsId,
35+
),
36+
)
37+
38+
eoiQueueSqsClient.sendMessage(
39+
SendMessageRequest.builder()
40+
.queueUrl(eoiQueueUrl)
41+
.messageBody(messageBody)
42+
.build(),
43+
)
44+
} catch (e: Exception) {
45+
throw MessageFailedException("Failed to send message to SQS")
46+
}
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.controllers.v1.person
2+
3+
import io.kotest.core.spec.style.DescribeSpec
4+
import io.kotest.matchers.shouldBe
5+
import org.mockito.Mockito
6+
import org.mockito.kotlin.verify
7+
import org.mockito.kotlin.whenever
8+
import org.springframework.beans.factory.annotation.Autowired
9+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
10+
import org.springframework.http.HttpStatus
11+
import org.springframework.test.context.ActiveProfiles
12+
import org.springframework.test.context.bean.override.mockito.MockitoBean
13+
import org.springframework.test.web.servlet.MockMvc
14+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.helpers.IntegrationAPIMockMvc
15+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.expressionOfInterest.ExpressionInterest
16+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Identifiers
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.OffenderSearchResponse
18+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Person
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.PersonOnProbation
20+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
21+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
22+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
23+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.prisoneroffendersearch.POSPrisoner
24+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.ExpressionInterestService
25+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetPersonService
26+
27+
@WebMvcTest(controllers = [ExpressionInterestController::class])
28+
@ActiveProfiles("test")
29+
class ExpressionInterestControllerTest(
30+
@Autowired private val springMockMvc: MockMvc,
31+
@MockitoBean private val getPersonService: GetPersonService,
32+
@MockitoBean private val expressionOfInterestService: ExpressionInterestService,
33+
) : DescribeSpec({
34+
35+
val mockMvc = IntegrationAPIMockMvc(springMockMvc)
36+
val basePath = "/v1/persons"
37+
val validHmppsId = "1234ABC"
38+
val invalidHmppsId = "INVALID_ID"
39+
val jobId = "5678"
40+
41+
describe("PUT $basePath/{hmppsId}/expression-of-interest/jobs/{jobId}") {
42+
43+
beforeTest {
44+
Mockito.reset(getPersonService, expressionOfInterestService)
45+
}
46+
47+
it("should return 201 Created when the expression of interest is successfully submitted") {
48+
val personOnProbation =
49+
PersonOnProbation(
50+
person =
51+
Person(
52+
"Sam",
53+
"Smith",
54+
identifiers = Identifiers(nomisNumber = validHmppsId),
55+
hmppsId = validHmppsId,
56+
currentExclusion = true,
57+
exclusionMessage = "An exclusion exists",
58+
currentRestriction = false,
59+
),
60+
underActiveSupervision = true,
61+
)
62+
63+
val probationResponse = Response(data = personOnProbation, errors = emptyList())
64+
65+
val prisonOffenderSearch = POSPrisoner("Kim", "Kardashian")
66+
val prisonResponse = Response(data = prisonOffenderSearch, errors = emptyList())
67+
68+
val offenderMap =
69+
OffenderSearchResponse(
70+
probationOffenderSearch = probationResponse.data,
71+
prisonerOffenderSearch = prisonResponse.data.toPerson(),
72+
)
73+
74+
whenever(getPersonService.getCombinedDataForPerson(validHmppsId)).thenReturn(
75+
Response(data = offenderMap, errors = emptyList()),
76+
)
77+
78+
val result = mockMvc.performAuthorised("$basePath/$validHmppsId/expression-of-interest/jobs/$jobId")
79+
80+
result.response.status.shouldBe(HttpStatus.CREATED.value())
81+
verify(expressionOfInterestService).sendExpressionOfInterest(ExpressionInterest(jobId, validHmppsId))
82+
}
83+
84+
it("should return 400 Bad Request when the HMPPS ID is not found") {
85+
whenever(getPersonService.getCombinedDataForPerson(invalidHmppsId)).thenReturn(
86+
Response(
87+
data =
88+
OffenderSearchResponse(
89+
probationOffenderSearch = null,
90+
prisonerOffenderSearch = null,
91+
),
92+
errors =
93+
listOf(
94+
UpstreamApiError(
95+
type = UpstreamApiError.Type.ENTITY_NOT_FOUND,
96+
causedBy = UpstreamApi.PROBATION_OFFENDER_SEARCH,
97+
),
98+
),
99+
),
100+
)
101+
102+
val result = mockMvc.performAuthorised("$basePath/$invalidHmppsId/expression-of-interest/jobs/$jobId")
103+
104+
result.response.status.shouldBe(HttpStatus.BAD_REQUEST.value())
105+
}
106+
107+
it("should return 500 Internal Server Error when an unexpected error occurs") {
108+
whenever(getPersonService.getCombinedDataForPerson(validHmppsId)).thenThrow(RuntimeException("Unexpected error"))
109+
110+
val result = mockMvc.performAuthorised("$basePath/$validHmppsId/expression-of-interest/jobs/$jobId")
111+
112+
result.response.status.shouldBe(HttpStatus.INTERNAL_SERVER_ERROR.value())
113+
}
114+
}
115+
})

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ class AdjudicationsIntegrationTest : IntegrationTestBase() {
1010
fun `returns adjudications for a person`() {
1111
callApi("$basePath/$nomsId/reported-adjudications")
1212
.andExpect(status().isOk)
13-
.andExpect(content().json(getExpectedResponse("person-adjudications"), true))
13+
.andExpect(content().json(getExpectedResponse("person-adjudications")))
1414
}
1515
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.services
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.core.spec.style.DescribeSpec
6+
import io.kotest.matchers.shouldBe
7+
import org.mockito.kotlin.any
8+
import org.mockito.kotlin.argThat
9+
import org.mockito.kotlin.doReturn
10+
import org.mockito.kotlin.mock
11+
import org.mockito.kotlin.never
12+
import org.mockito.kotlin.reset
13+
import org.mockito.kotlin.verify
14+
import org.mockito.kotlin.whenever
15+
import software.amazon.awssdk.services.sqs.SqsAsyncClient
16+
import software.amazon.awssdk.services.sqs.model.SendMessageRequest
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.MessageFailedException
18+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.expressionOfInterest.ExpressionInterest
19+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.ExpressionOfInterestMessage
20+
import uk.gov.justice.hmpps.sqs.HmppsQueue
21+
import uk.gov.justice.hmpps.sqs.HmppsQueueService
22+
23+
class ExpressionInterestServiceTest : DescribeSpec({
24+
val mockQueueService = mock<HmppsQueueService>()
25+
val mockSqsClient = mock<SqsAsyncClient>()
26+
val mockObjectMapper = mock<ObjectMapper>()
27+
28+
val eoiQueue =
29+
mock<HmppsQueue> {
30+
on { sqsClient } doReturn mockSqsClient
31+
on { queueUrl } doReturn "https://test-queue-url"
32+
}
33+
34+
val service = ExpressionInterestService(mockQueueService, mockObjectMapper)
35+
36+
beforeTest {
37+
reset(mockQueueService, mockSqsClient, mockObjectMapper)
38+
whenever(mockQueueService.findByQueueId("eoi-queue")).thenReturn(eoiQueue)
39+
}
40+
41+
describe("sendExpressionOfInterest") {
42+
it("should send a valid message successfully to SQS") {
43+
val expressionInterest = ExpressionInterest(jobId = "12345", hmppsId = "H1234")
44+
val messageBody = """{"jobId":"12345","hmppsId":"H1234"}"""
45+
46+
whenever(mockObjectMapper.writeValueAsString(any<ExpressionOfInterestMessage>()))
47+
.thenReturn(messageBody)
48+
49+
service.sendExpressionOfInterest(expressionInterest)
50+
51+
verify(mockSqsClient).sendMessage(
52+
argThat<SendMessageRequest> { request: SendMessageRequest? ->
53+
(
54+
request?.queueUrl() == "https://test-queue-url" &&
55+
request.messageBody() == messageBody
56+
)
57+
},
58+
)
59+
}
60+
61+
it("should throw MessageFailedException when SQS fails") {
62+
val expressionInterest = ExpressionInterest(jobId = "12345", hmppsId = "H1234")
63+
64+
whenever(mockObjectMapper.writeValueAsString(any<ExpressionOfInterestMessage>()))
65+
.thenReturn("""{"jobId":"12345","hmppsId":"H1234"}""")
66+
67+
whenever(mockSqsClient.sendMessage(any<SendMessageRequest>()))
68+
.thenThrow(RuntimeException("Failed to send message to SQS"))
69+
70+
val exception =
71+
shouldThrow<MessageFailedException> {
72+
service.sendExpressionOfInterest(expressionInterest)
73+
}
74+
75+
exception.message shouldBe "Failed to send message to SQS"
76+
}
77+
78+
it("should prevent messages with null hmppsId being sent") {
79+
val invalidExpressionInterest = ExpressionInterest(jobId = "12345", hmppsId = null)
80+
81+
service.sendExpressionOfInterest(invalidExpressionInterest)
82+
83+
verify(mockSqsClient, never()).sendMessage(any<SendMessageRequest>())
84+
}
85+
}
86+
})

0 commit comments

Comments
 (0)