Skip to content

Commit

Permalink
BTD-543: Confirm Match Endpoint (#97)
Browse files Browse the repository at this point in the history
* [BTD-541]

* [BTD-541] Added check match end point

* [BTD-541] Added check match end point

* [BTD-541] Added check match end point

* [BTD-541] Added check match end point

* [BTD-541] Added check match end point

* [BTD-541] Added doco

* [BTD-541] Added doco

* [BTD-541] Added doco

* [BTD-541]

* [BTD-541] Fixed errors

* [BTD-541] Fixed errors

* [BTD-541] Fixed errors

* Request model

* Response model

* Confirm match endpoint + swagger docs

* Int tests

* Int test fix

* Model validation test

* Match Service Test update

* Fixes for openapi

* Readme

* nomis id consistency

* Remove response body KEEP request body

* additional comments

* readme

* readme update

* readme update

* Readme

* Readme

---------

Co-authored-by: Patrick Lucas <patrick.lucas@justice.gov.uk>
Co-authored-by: Ewan Donovan <ewan.donovan@digital.justice.co.uk>
  • Loading branch information
3 people authored Feb 21, 2025
1 parent bade5ff commit 3d4c730
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 16 deletions.
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ This service is available at:
## Endpoints

The service provides the following endpoints to consumers.
* `/match/:id` - Search for a learner's ULN via their NOMIS ID
* `/learners` - Search for a learner's ULN via their demographic data
* `/learner-events` - Request a learner's learning record via their ULN
* `GET /match/:nomisId` - Search for a learner's ULN via their NOMIS ID
* `POST /match/:nomisId` - Confirm a match between a learner's NOMIS ID and ULN
* `POST /learners` - Search for a learner's ULN via their demographic data
* `POST /learner-events` - Request a learner's learning record via their ULN

### `GET:/match/:id`
### `GET:/match/:nomisId`
This endpoint is to search for a ULN given a NOMIS ID. The response will
be OK (200) with the ULN if a match exists and NOT_FOUND (404) if there
is no match.

Example response body:
```json
{
"matchedUln": "a1234",
"matchedUln": "1234567890",
"status": "Found"
}
```
Expand All @@ -42,7 +43,7 @@ In the response body, the `status` will have one of the following values
ax explained below.
* `Found` = A match has been found for `id` and ULN is in `matchedUln`
* `NotFound` = No match has been found for `id`
* `NoMatch` = `id` cannot be matched
* `NoMatch` = `nomisId` cannot be matched

Response codes:
* 200 - Success
Expand All @@ -51,6 +52,24 @@ Response codes:
* 403 - Forbidden
* 404 - Not found

### `POST:/match/:nomisId`
This endpoint is to confirm a match between a learner's NOMIS ID and ULN.
The match will be saved as a `MatchEntity` in the database.

Example request body:
```json
{
"matchingUln": "1234567890"
}
```

Response codes:
* 201 - Created
* 400 - Bad Request, malformed ULN or json body.
* 401 - Unauthorised
* 403 - Forbidden
* 500 - Likely that database is unreachable

### `POST:/learners`
This endpoint is to search for learners by their demographic information.
The search may yield varied results depending on the accuracy of the demographic information and the DfE data available:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package uk.gov.justice.digital.hmpps.learnerrecordsapi.models.request

import jakarta.validation.constraints.Pattern

class ConfirmMatchRequest(

@field:Pattern(regexp = "^[0-9]{1,10}\$")
val matchingUln: String,

)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package uk.gov.justice.digital.hmpps.learnerrecordsapi.openapi

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.ExampleObject
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.request.ConfirmMatchRequest
import uk.gov.justice.hmpps.kotlin.common.ErrorResponse

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Operation(
summary = "Confirm a match",
description = "Confirm a match between a nomis id and a ULN",
parameters = [
Parameter(name = "X-Username", `in` = ParameterIn.HEADER, required = true),
Parameter(name = "nomisId", `in` = ParameterIn.PATH, required = true),
],
requestBody = RequestBody(
description = "a ULN to match with the nomis id in the path",
required = true,
content = [
Content(
mediaType = "application/json",
schema = Schema(implementation = ConfirmMatchRequest::class),
examples = [
ExampleObject(
name = "Example Request",
value = """
{
"matchingUln": "1964986809"
}
""",
),
],
),
],
),
security = [SecurityRequirement(name = "learner-records-search-read-only-role")],
responses = [
ApiResponse(
responseCode = "201",
description = "The request was successful and the match was created.",
),
ApiResponse(
responseCode = "401",
description = "Unauthorized to access this endpoint",
content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))],
),
ApiResponse(
responseCode = "403",
description = "Forbidden to access this endpoint",
content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))],
),
],
)
annotation class MatchConfirmApi
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
package uk.gov.justice.digital.hmpps.learnerrecordsapi.resource

import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import uk.gov.justice.digital.hmpps.learnerrecordsapi.logging.LoggerUtil
import uk.gov.justice.digital.hmpps.learnerrecordsapi.logging.LoggerUtil.log
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.db.MatchEntity
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.request.ConfirmMatchRequest
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.response.CheckMatchResponse
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.response.CheckMatchStatus
import uk.gov.justice.digital.hmpps.learnerrecordsapi.openapi.MatchCheckApi
import uk.gov.justice.digital.hmpps.learnerrecordsapi.openapi.MatchConfirmApi
import uk.gov.justice.digital.hmpps.learnerrecordsapi.service.MatchService
import java.net.URI

@RestController
@PreAuthorize("hasRole('ROLE_LEARNER_RECORDS_SEARCH__RO')")
Expand Down Expand Up @@ -58,4 +64,17 @@ class MatchResource(
),
)
}

@PostMapping(value = ["/{nomisId}"])
@Tag(name = "Match")
@MatchConfirmApi
suspend fun confirmMatch(
@RequestHeader("X-Username", required = true) userName: String,
@PathVariable(name = "nomisId", required = true) nomisId: String,
@RequestBody @Valid confirmMatchRequest: ConfirmMatchRequest,
): ResponseEntity<Void> {
logger.log("Received a post request to confirm match endpoint", confirmMatchRequest)
matchService.saveMatch(MatchEntity(nomisId, confirmMatchRequest.matchingUln))
return ResponseEntity.created(URI.create("/match/$nomisId")).build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,53 @@ import com.fasterxml.jackson.databind.ObjectMapper
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.reset
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.never
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.reactive.server.WebTestClient
import uk.gov.justice.digital.hmpps.learnerrecordsapi.config.HmppsBoldLrsExceptionHandler
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.db.MatchEntity
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.request.ConfirmMatchRequest
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.response.CheckMatchResponse
import uk.gov.justice.digital.hmpps.learnerrecordsapi.models.response.CheckMatchStatus
import uk.gov.justice.digital.hmpps.learnerrecordsapi.repository.MatchRepository
import uk.gov.justice.digital.hmpps.learnerrecordsapi.service.MatchService

@TestConfiguration
class MockitoSpyConfig {
@Autowired
lateinit var matchRepository: MatchRepository

@Bean
fun matchService(): MatchService = spy(MatchService(matchRepository))
}

@Import(MockitoSpyConfig::class)
class MatchResourceIntTest : IntegrationTestBase() {

@Autowired
protected lateinit var objectMapper: ObjectMapper

@Autowired
protected lateinit var matchRepository: MatchRepository
lateinit var matchRepository: MatchRepository

@Autowired
protected lateinit var matchService: MatchService

val nomisId = "A1234BC"
val matchedUln = "A"

private fun checkWebCall(
private fun checkGetWebCall(
nomisId: String,
expectedResponseStatus: Int,
expectedStatus: CheckMatchStatus,
Expand Down Expand Up @@ -50,15 +78,26 @@ class MatchResourceIntTest : IntegrationTestBase() {
}
}

private fun postMatch(nomisId: String, uln: String, expectedStatus: Int): WebTestClient.ResponseSpec = webTestClient.post()
.uri("/match/$nomisId")
.headers(setAuthorisation(roles = listOf("ROLE_LEARNER_RECORDS_SEARCH__RO")))
.header("X-Username", "TestUser")
.bodyValue(ConfirmMatchRequest(matchingUln = uln))
.accept(MediaType.parseMediaType("application/json"))
.exchange()
.expectStatus()
.isEqualTo(expectedStatus)

@AfterEach
fun cleanup() {
matchRepository.deleteAll()
reset(matchService)
}

@Test
fun `should find a match by id`() {
fun `GET match should find a match by id`() {
matchRepository.save(MatchEntity(nomisId, matchedUln))
checkWebCall(
checkGetWebCall(
nomisId,
200,
CheckMatchStatus.Found,
Expand All @@ -67,21 +106,70 @@ class MatchResourceIntTest : IntegrationTestBase() {
}

@Test
fun `should return NOT_FOUND if no match`() {
checkWebCall(
fun `GET match should return NOT_FOUND if no match`() {
checkGetWebCall(
nomisId,
404,
CheckMatchStatus.NotFound,
)
}

@Test
fun `should return no match if record marked as such`() {
fun `GET match should return no match if record marked as such`() {
matchRepository.save(MatchEntity(nomisId, ""))
checkWebCall(
checkGetWebCall(
nomisId,
200,
CheckMatchStatus.NoMatch,
)
}

@Test
fun `POST to confirm match should return 201 CREATED with a response confirming a match`() {
val (nomisId, uln) = arrayOf("A1417AE", "1234567890")
val actualResponse = postMatch(nomisId, uln, 201)
verify(matchService, times(1)).saveMatch(MatchEntity(1, nomisId, uln))
actualResponse.expectStatus().isCreated
}

@Test
fun `POST to confirm match should return 400 if ULN is malformed`() {
val (nomisId, uln) = arrayOf("A1417AE", "1234567890abcdef")
val actualResponse = objectMapper.readValue(
postMatch(nomisId, uln, 400).expectBody().returnResult().responseBody,
HmppsBoldLrsExceptionHandler.ErrorResponse::class.java,
)

val expectedError = HmppsBoldLrsExceptionHandler.ErrorResponse(
HttpStatus.BAD_REQUEST,
errorCode = "Validation Failed",
userMessage = "Please correct the error and retry",
developerMessage = "Validation(s) failed for [matchingUln]",
moreInfo = "Validation(s) failed for [matchingUln] with reason(s): [must match \"^[0-9]{1,10}\$\"]",
)

verify(matchService, never()).saveMatch(any())
assertThat(actualResponse).isEqualTo(expectedError)
}

@Test
fun `POST to confirm match should return 500 if match service fails to save`() {
val (nomisId, uln) = arrayOf("A1417AE", "1234567890")
doThrow(RuntimeException("Database error")).`when`(matchService).saveMatch(any())
val actualResponse = objectMapper.readValue(
postMatch(nomisId, uln, 500).expectBody().returnResult().responseBody,
HmppsBoldLrsExceptionHandler.ErrorResponse::class.java,
)

val expectedError = HmppsBoldLrsExceptionHandler.ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
errorCode = "Unexpected error",
userMessage = "Unexpected error: Database error",
developerMessage = "Unexpected error: Database error",
moreInfo = "Unexpected error",
)

verify(matchService, times(1)).saveMatch(any())
assertThat(actualResponse).isEqualTo(expectedError)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package uk.gov.justice.digital.hmpps.learnerrecordsapi.models.request

import jakarta.validation.Validation
import jakarta.validation.ValidatorFactory
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class ConfirmMatchRequestTest {

private val factory: ValidatorFactory = Validation.buildDefaultValidatorFactory()
private val validator = factory.validator

@Test
fun `valid uln should pass validation`() {
val request = ConfirmMatchRequest(
matchingUln = "1234567890",
)

assertTrue(validator.validate(request).isEmpty())
}

@Test
fun `invalid uln should fail validation`() {
val request = ConfirmMatchRequest(
matchingUln = "1234567890abcdedf",
)

assertTrue(validator.validate(request).size == 1)
}
}
Loading

0 comments on commit 3d4c730

Please sign in to comment.