diff --git a/README.md b/README.md index 157bb348..2fb003b7 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,12 @@ 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. @@ -33,7 +34,7 @@ is no match. Example response body: ```json { - "matchedUln": "a1234", + "matchedUln": "1234567890", "status": "Found" } ``` @@ -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 @@ -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: diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequest.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequest.kt new file mode 100644 index 00000000..4f668d4a --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequest.kt @@ -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, + +) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/openapi/MatchConfirmApi.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/openapi/MatchConfirmApi.kt new file mode 100644 index 00000000..8d2af2b6 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/openapi/MatchConfirmApi.kt @@ -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 diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/resource/MatchResource.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/resource/MatchResource.kt index 345aa9ec..44b4383b 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/resource/MatchResource.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/resource/MatchResource.kt @@ -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')") @@ -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 { + 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() + } } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/integration/MatchResourceIntTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/integration/MatchResourceIntTest.kt index c23c8ed5..60b3da54 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/integration/MatchResourceIntTest.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/integration/MatchResourceIntTest.kt @@ -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, @@ -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, @@ -67,8 +106,8 @@ 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, @@ -76,12 +115,61 @@ class MatchResourceIntTest : IntegrationTestBase() { } @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) + } } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequestTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequestTest.kt new file mode 100644 index 00000000..9377c664 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/models/request/ConfirmMatchRequestTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/service/MatchServiceTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/service/MatchServiceTest.kt index 2ba7977e..3ea5a157 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/service/MatchServiceTest.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/learnerrecordsapi/service/MatchServiceTest.kt @@ -26,7 +26,7 @@ class MatchServiceTest { } @Test - fun `should return null if no record found`() { + fun `findMatch should return null if no record found`() { `when`(mockMatchRepository.findFirstByNomisIdOrderByIdDesc(any())).thenReturn(null) val actual = matchService.findMatch( @@ -38,7 +38,7 @@ class MatchServiceTest { } @Test - fun `should return entity if record found`() { + fun `findMatch should return entity if record found`() { `when`(mockMatchRepository.findFirstByNomisIdOrderByIdDesc(any())).thenReturn( MatchEntity( nomisId = nomisId, @@ -54,4 +54,24 @@ class MatchServiceTest { assertThat(actual).isNotEqualTo(null) assertThat(actual?.matchedUln).isEqualTo(matchedUln) } + + @Test + fun `saveMatch should return the entity the it saves via the repository`() { + `when`(mockMatchRepository.save(any())).thenReturn( + MatchEntity( + nomisId = nomisId, + matchedUln = matchedUln, + ), + ) + + val saved = matchService.saveMatch( + MatchEntity( + nomisId = nomisId, + matchedUln = matchedUln, + ), + ) + assertThat(saved).isNotEqualTo(null) + assertThat(saved.nomisId).isEqualTo(nomisId) + assertThat(saved.matchedUln).isEqualTo(matchedUln) + } }