Skip to content

Commit

Permalink
NON-375: Paginate calls to offender search (#382)
Browse files Browse the repository at this point in the history
* Paginate calls to offender search to avoid 1000 prisoner number limit
* Have dependabot update @types/node for node 22
  • Loading branch information
ushkarev authored Nov 6, 2024
1 parent 4d85087 commit c0ce502
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ updates:
ignore:
- dependency-name: "@types/node"
versions:
- ">=21"
- ">=23"
schedule:
interval: weekly
groups:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.service
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono
import reactor.core.publisher.Flux
import uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.config.MissingPrisonersInSearchException
import uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.dto.offendersearch.OffenderSearchPrisoner

Expand All @@ -22,16 +23,23 @@ class OffenderSearchService(
return emptyMap()
}

val requestBody = mapOf("prisonerNumbers" to prisonerNumbers.toSet())

val foundPrisoners = offenderSearchWebClient
.post()
.uri("/prisoner-search/prisoner-numbers")
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono<List<OffenderSearchPrisoner>>()
val requests = prisonerNumbers
.toSet()
.chunked(900)
.map { pageOfPrisonerNumbers ->
val requestBody = mapOf("prisonerNumbers" to pageOfPrisonerNumbers)
offenderSearchWebClient
.post()
.uri("/prisoner-search/prisoner-numbers")
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono<List<OffenderSearchPrisoner>>()
}
val foundPrisoners = Flux.merge(requests)
.collectList()
.block()!!
.flatten()
.associateBy(OffenderSearchPrisoner::prisonerNumber)

// Throw an exception if any of the prisoners searched were not found
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.service

import com.fasterxml.jackson.databind.ObjectMapper
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.config.MissingPrisonersInSearchException
import uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.dto.offendersearch.OffenderSearchPrisoner
import uk.gov.justice.digital.hmpps.hmppsnonassociationsapi.util.offenderSearchPrisoners

@DisplayName("Offender search service")
class OffenderSearchServiceTest {
private val mapper = ObjectMapper().findAndRegisterModules()

private fun createWebClientMockResponses(vararg responses: List<OffenderSearchPrisoner>): WebClient {
val responseIterator = responses.iterator()
return WebClient.builder()
.exchangeFunction {
val response = if (!responseIterator.hasNext()) {
ClientResponse.create(HttpStatus.NOT_FOUND).build()
} else {
ClientResponse.create(HttpStatus.OK)
.header("Content-Type", "application/json")
.body(mapper.writeValueAsString(responseIterator.next()))
.build()
}
Mono.just(response)
}
.build()
}

@Test
fun `returns an empty map when passed no prisoner numbers`() {
val webClient = createWebClientMockResponses()
val offenderSearchService = OffenderSearchService(webClient)
val prisoners = offenderSearchService.searchByPrisonerNumbers(emptyList())
assertThat(prisoners).isEmpty()
}

@Test
fun `calls offender search once if passed few prisoner numbers`() {
val webClient = createWebClientMockResponses(offenderSearchPrisoners.values.toList())
val offenderSearchService = OffenderSearchService(webClient)
val prisoners = offenderSearchService.searchByPrisonerNumbers(offenderSearchPrisoners.keys.toList())
assertThat(prisoners).hasSize(offenderSearchPrisoners.size)
}

@Test
fun `calls offender search repeatedly in pages of 900`() {
// generate 900 prisoners for page 1
val response1 = (1..900).map {
OffenderSearchPrisoner(
String.format("A%04dAA", it),
"First name",
"Surname",
null,
null,
null,
)
}
// generate 900 prisoners for page 2
val response2 = (900..<1800).map {
OffenderSearchPrisoner(
String.format("A%04dAA", it),
"First name",
"Surname",
null,
null,
null,
)
}
// generate 200 prisoners for page 3
val response3 = (1800..2000).map {
OffenderSearchPrisoner(
String.format("A%04dAA", it),
"First name",
"Surname",
null,
null,
null,
)
}

val webClient = createWebClientMockResponses(response1, response2, response3)
val offenderSearchService = OffenderSearchService(webClient)
val prisoners = offenderSearchService.searchByPrisonerNumbers((1..2000).map { String.format("A%04dAA", it) })
assertThat(prisoners).hasSize(2000)
}

@Test
fun `throws an error if any prisoners are not found`() {
val webClient = createWebClientMockResponses(emptyList())
val offenderSearchService = OffenderSearchService(webClient)
assertThatThrownBy {
offenderSearchService.searchByPrisonerNumbers(listOf("A1234AA"))
}.isInstanceOf(MissingPrisonersInSearchException::class.java).hasMessageContaining("A1234AA")
}
}

0 comments on commit c0ce502

Please sign in to comment.