diff --git a/pom.xml b/pom.xml index 55585f8..15985d7 100644 --- a/pom.xml +++ b/pom.xml @@ -138,6 +138,13 @@ ${lucene.version} + + + me.xdrop + fuzzywuzzy + 1.4.0 + + org.postgresql diff --git a/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt b/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt index a05ada4..7439762 100644 --- a/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt +++ b/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt @@ -18,6 +18,7 @@ import no.nb.bikube.core.exception.NotSupportedException import no.nb.bikube.core.model.CatalogueRecord import no.nb.bikube.core.model.Item import no.nb.bikube.core.model.Title +import no.nb.bikube.core.service.SearchFilterService import no.nb.bikube.newspaper.service.NewspaperService import no.nb.bikube.newspaper.service.TitleIndexService import org.springframework.http.MediaType @@ -36,7 +37,8 @@ import java.time.LocalDate @RequestMapping("") class CoreController ( private val newspaperService: NewspaperService, - private val titleIndexService: TitleIndexService + private val titleIndexService: TitleIndexService, + private val searchFilterService: SearchFilterService ){ companion object { const val DATE_REGEX = "^(17|18|19|20)\\d{2}(-)?(0[1-9]|1[0-2])(-)?(0[1-9]|[12][0-9]|3[01])$" @@ -88,12 +90,22 @@ class CoreController ( ApiResponse(responseCode = "500", description = "Server error", content = [Content()]) ]) fun searchTitle( + @Parameter(description = "Search term") @RequestParam searchTerm: String, - @RequestParam materialType: MaterialType + @Parameter(description = "Material type of the titles to search for") + @RequestParam materialType: MaterialType, + @Parameter(description = "Date in ISO-8601 format (YYYY-MM-DD). " + + "If provided, only titles with start and end date that includes this date will be returned.") + @RequestParam date: LocalDate? = null, + @Parameter(description = "Whether or not to select only the best match for search term") + @RequestParam selectBestMatch: Boolean = false ): ResponseEntity> { if (searchTerm.isEmpty()) throw BadRequestBodyException("Search term cannot be empty.") return when(materialTypeToCatalogueName(materialType)) { - CatalogueName.COLLECTIONS -> ResponseEntity.ok(titleIndexService.searchTitle(searchTerm)) + CatalogueName.COLLECTIONS -> { + val searchResult = titleIndexService.searchTitle(searchTerm) + ResponseEntity.ok(searchFilterService.filterSearchResults(searchResult, searchTerm, date, selectBestMatch)) + } else -> throw NotSupportedException("Material type $materialType is not supported.") } } diff --git a/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt b/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt new file mode 100644 index 0000000..8277a48 --- /dev/null +++ b/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt @@ -0,0 +1,31 @@ +package no.nb.bikube.core.service + +import me.xdrop.fuzzywuzzy.FuzzySearch +import no.nb.bikube.core.model.Title +import no.nb.bikube.core.util.logger +import org.springframework.stereotype.Service +import java.time.LocalDate + +@Service +class SearchFilterService { + fun filterSearchResults(searchResult: List, searchTerm: String, date: LocalDate?, selectBestMatch: Boolean?): List<Title> { + val filteredResult = date?.let { + searchResult.filter { title -> + (title.startDate == null || !it.isBefore(title.startDate)) && + (title.endDate == null || !it.isAfter(title.endDate)) + } + } ?: searchResult + + val scores = FuzzySearch.extractAll(searchTerm, filteredResult.map { it.name }) + logger().debug("Fuzzy search scores {}", scores) + + val bestMatch = scores.maxByOrNull { it.score } + logger().debug("Fuzzy search best match {}", bestMatch) + + return if (selectBestMatch == true && bestMatch != null && bestMatch.score > 50) { + listOf(filteredResult[bestMatch.index]) + } else { + filteredResult + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt index 8a99a29..2ce1152 100644 --- a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt +++ b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt @@ -8,6 +8,7 @@ import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.col import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockItemA import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockManifestationA import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleA +import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleB import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockItemA import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockManifestationA import no.nb.bikube.catalogue.collections.enum.CollectionsFormat @@ -30,9 +31,11 @@ import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec import org.springframework.test.web.reactive.server.expectBody import org.springframework.test.web.reactive.server.returnResult +import org.springframework.web.util.UriBuilder import reactor.core.publisher.Mono import reactor.kotlin.test.test import java.time.Duration +import java.time.LocalDate @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @@ -89,13 +92,21 @@ class CoreControllerIntegrationTest ( .exchange() } - private fun searchTitle(search: String, materialType: MaterialType): ResponseSpec { + private fun searchTitle(search: String, materialType: MaterialType, date: LocalDate? = null, selectBestMatch: Boolean? = null): ResponseSpec { return webClient .get() .uri { uri -> uri.pathSegment("title", "search") .queryParam("searchTerm", search) .queryParam("materialType", materialType) + .apply { + if (date != null) { + queryParam("date", date) + } + if (selectBestMatch != null) { + queryParam("selectBestMatch", selectBestMatch) + } + } .build() } .exchange() @@ -322,6 +333,29 @@ class CoreControllerIntegrationTest ( .verifyComplete() } + @Test + fun `get-title-search endpoint should return best match if query parameter is present`() { + searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, selectBestMatch = true) + .returnResult<Title>() + .responseBody + .test() + .expectSubscription() + .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject())) + .verifyComplete() + } + + @Test + fun `get-title-search endpoint should filter by date if query parameter is present`() { + searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, date = LocalDate.parse("2021-01-01")) + .returnResult<Title>() + .responseBody + .test() + .expectSubscription() + .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject())) + .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleB.getFirstObject())) + .verifyComplete() + } + @Test fun `search-item endpoint should return 200 OK with all items`() { webClient diff --git a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt index b2d08bc..5043749 100644 --- a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt +++ b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt @@ -8,6 +8,7 @@ import no.nb.bikube.core.enum.MaterialType import no.nb.bikube.core.exception.BadRequestBodyException import no.nb.bikube.core.exception.NotSupportedException import no.nb.bikube.core.model.Title +import no.nb.bikube.core.service.SearchFilterService import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperItemMockA import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockA import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockB @@ -35,6 +36,9 @@ class CoreControllerTest { @MockkBean private lateinit var titleIndexService: TitleIndexService + @MockkBean + private lateinit var searchFilterService: SearchFilterService + @Test fun `get single item for newspaper should return item in body`() { every { newspaperService.getSingleItem(any()) } returns Mono.just(newspaperItemMockA.copy()) @@ -97,6 +101,10 @@ class CoreControllerTest { newspaperTitleMockA.copy(), newspaperTitleMockB.copy() ) + every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf( + newspaperTitleMockA.copy(), newspaperTitleMockB.copy() + ) + Assertions.assertEquals( coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!, listOf( @@ -117,6 +125,7 @@ class CoreControllerTest { @Test fun `search title should call on titleIndexService function when materialType is NEWSPAPER`() { every { titleIndexService.searchTitle(any()) } returns emptyList() + every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns emptyList() Assertions.assertEquals( coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!, @@ -126,6 +135,24 @@ class CoreControllerTest { verify(exactly = 1) { titleIndexService.searchTitle(any()) } } + @Test + fun `search title should call on searchFilterService function when materialType is NEWSPAPER`() { + every { titleIndexService.searchTitle(any()) } returns listOf( + newspaperTitleMockA.copy(), newspaperTitleMockB.copy() + ) + every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf( + newspaperTitleMockA.copy(), newspaperTitleMockB.copy() + ) + + Assertions.assertEquals( + coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!, + listOf( + newspaperTitleMockA.copy(), newspaperTitleMockB.copy() + ) + ) + verify(exactly = 1) { searchFilterService.filterSearchResults(any(), any(), any(), any()) } + } + @Test fun `search item should return a list of items matching criteria`() { every { newspaperService.getItemsByTitleAndDate(any(), any(), any()) } returns Flux.just( diff --git a/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt b/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt new file mode 100644 index 0000000..d3ceb81 --- /dev/null +++ b/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt @@ -0,0 +1,87 @@ +package no.nb.bikube.core.service + +import no.nb.bikube.core.enum.MaterialType +import no.nb.bikube.core.model.Title +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import java.time.LocalDate + +@SpringBootTest +@ActiveProfiles("test") +class SearchFilterServiceTest { + @Autowired + private lateinit var searchFilterService: SearchFilterService + + private val titleA: Title = Title( + name = "A-Avisen", + startDate = LocalDate.parse("1900-01-01"), + endDate = LocalDate.parse("1925-12-31"), + catalogueId = "1", + publisher = null, + publisherPlace = null, + language = null, + materialType = MaterialType.NEWSPAPER.norwegian + ) + + private val titleB = Title( + name = "B-Avisen", + startDate = LocalDate.parse("1950-01-01"), + endDate = LocalDate.parse("1999-12-31"), + catalogueId = "2", + publisher = null, + publisherPlace = null, + language = null, + materialType = MaterialType.NEWSPAPER.norwegian + ) + + private val titleC = Title( + name = "C-Avisen", + startDate = LocalDate.parse("1998-01-01"), + endDate = null, + catalogueId = "3", + publisher = null, + publisherPlace = null, + language = null, + materialType = MaterialType.NEWSPAPER.norwegian + ) + + private val results: List<Title> = listOf(titleA, titleB, titleC) + + @Test + fun `filterSearchResults should filter by date`() { + val date1 = LocalDate.parse("1990-01-01") + val filteredResults1 = searchFilterService.filterSearchResults(results, "", date1, false) + assert(filteredResults1.size == 1) + assert(filteredResults1.contains(titleB)) + + val date2 = LocalDate.parse("1950-01-01") + val filteredResults2 = searchFilterService.filterSearchResults(results, "", date2, true) + assert(filteredResults2.size == 1) + assert(filteredResults2.contains(titleB)) + + val date3 = LocalDate.parse("1999-01-01") + val filteredResults3 = searchFilterService.filterSearchResults(results, "", date3, false) + assert(filteredResults3.size == 2) + assert(filteredResults3.contains(titleB)) + assert(filteredResults3.contains(titleC)) + } + + @Test + fun `filterSearchResults should select best match`() { + val searchTerm = "B-Avisen" + val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true) + assert(filteredResults.size == 1) + assert(filteredResults.contains(titleB)) + } + + @Test + fun `filterSearchResults should return original results list if best match has too low score`() { + val searchTerm = "En helt annen tittel" // Gives 45 score + val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true) + assert(filteredResults.size == 3) + assert(filteredResults.containsAll(listOf(titleA, titleB, titleC))) + } + +} \ No newline at end of file