From b9a9813519ebb78bf89a7f41d14a0bda0a6e1dfe Mon Sep 17 00:00:00 2001 From: BKadirkhodjaev Date: Wed, 26 Jun 2024 15:21:05 +0500 Subject: [PATCH 1/3] [MSEARCH-773]. Return Unified List of Inventory Campuses in a Consortium --- NEWS.md | 1 + README.md | 1 + descriptors/ModuleDescriptor-template.json | 17 ++ .../SearchConsortiumController.java | 18 ++ .../model/dto/locationunit/CampusDto.java | 3 + .../ConsortiumCampusRepository.java | 79 +++++++++ .../consortium/ConsortiumCampusService.java | 46 ++++++ src/main/resources/model/campus.json | 25 +++ .../resources/swagger.api/mod-search.yaml | 3 + .../consortium-campuses-limit-param.yaml | 8 + .../parameters/sort-by-campus-name-param.yaml | 13 ++ .../search-consortium-campuses.yaml | 24 +++ .../schemas/entity/consortiumCampus.yaml | 16 ++ .../entity/consortiumCampusCollection.yaml | 8 + .../ConsortiumSearchCampusesIT.java | 115 +++++++++++++ .../ConsortiumCampusRepositoryTest.java | 155 ++++++++++++++++++ .../folio/search/sample/SampleCampuses.java | 20 +++ .../ConsortiumCampusServiceTest.java | 96 +++++++++++ .../search/support/base/ApiEndpoints.java | 8 + .../org/folio/search/utils/TestConstants.java | 5 + .../samples/campus-sample/campuses.json | 92 +++++++++++ 21 files changed, 753 insertions(+) create mode 100644 src/main/java/org/folio/search/repository/ConsortiumCampusRepository.java create mode 100644 src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java create mode 100644 src/main/resources/swagger.api/parameters/consortium-campuses-limit-param.yaml create mode 100644 src/main/resources/swagger.api/parameters/sort-by-campus-name-param.yaml create mode 100644 src/main/resources/swagger.api/paths/search-consortium/search-consortium-campuses.yaml create mode 100644 src/main/resources/swagger.api/schemas/entity/consortiumCampus.yaml create mode 100644 src/main/resources/swagger.api/schemas/entity/consortiumCampusCollection.yaml create mode 100644 src/test/java/org/folio/search/controller/ConsortiumSearchCampusesIT.java create mode 100644 src/test/java/org/folio/search/repository/ConsortiumCampusRepositoryTest.java create mode 100644 src/test/java/org/folio/search/sample/SampleCampuses.java create mode 100644 src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java create mode 100644 src/test/resources/samples/campus-sample/campuses.json diff --git a/NEWS.md b/NEWS.md index bd220d361..079cea9e8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -23,6 +23,7 @@ * Implement Indexing of Campuses from Kafka ([MSEARCH-770](https://issues.folio.org/browse/MSEARCH-770)) * Extend response with additional Location fields for Inventory Locations in a Consortium endpoint ([MSEARCH-775](https://folio-org.atlassian.net/browse/MSEARCH-775)) * Implement Indexing of Institutions from Kafka ([MSEARCH-771](https://issues.folio.org/browse/MSEARCH-771)) +* Return Unified List of Inventory Campuses in a Consortium ([MSEARCH-773](https://issues.folio.org/browse/MSEARCH-773)) ### Bug fixes * Do not delete kafka topics if collection topic is enabled ([MSEARCH-725](https://folio-org.atlassian.net/browse/MSEARCH-725)) diff --git a/README.md b/README.md index 346a80dcd..01c0a3369 100644 --- a/README.md +++ b/README.md @@ -869,6 +869,7 @@ Special API that provide consolidated access to records in consortium environmen | GET | `/search/consortium/holdings` | Returns consolidated holdings | | GET | `/search/consortium/items` | Returns consolidated items | | GET | `/search/consortium/locations` | Returns consolidated locations | +| GET | `/search/consortium/campuses` | Returns consolidated campuses | ## Additional Information diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index e741c054a..acc4ca3e3 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -189,6 +189,18 @@ "user-tenants.collection.get" ] }, + { + "methods": [ + "GET" + ], + "pathPattern": "/search/consortium/campuses", + "permissionsRequired": [ + "consortium-search.campuses.collection.get" + ], + "modulePermissions": [ + "user-tenants.collection.get" + ] + }, { "methods": [ "GET" @@ -724,6 +736,11 @@ "permissionName": "consortium-search.locations.collection.get", "displayName": "Consortium Search - fetch locations records", "description": "Returns location records in consortium" + }, + { + "permissionName": "consortium-search.campuses.collection.get", + "displayName": "Consortium Search - fetch campuses records", + "description": "Returns campus records in consortium" } ], "launchDescriptor": { diff --git a/src/main/java/org/folio/search/controller/SearchConsortiumController.java b/src/main/java/org/folio/search/controller/SearchConsortiumController.java index aa987a5ba..9e4c42739 100644 --- a/src/main/java/org/folio/search/controller/SearchConsortiumController.java +++ b/src/main/java/org/folio/search/controller/SearchConsortiumController.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.search.domain.dto.BatchIdsDto; +import org.folio.search.domain.dto.ConsortiumCampusCollection; import org.folio.search.domain.dto.ConsortiumHolding; import org.folio.search.domain.dto.ConsortiumHoldingCollection; import org.folio.search.domain.dto.ConsortiumItem; @@ -21,6 +22,7 @@ import org.folio.search.model.service.CqlSearchRequest; import org.folio.search.model.types.ResourceType; import org.folio.search.rest.resource.SearchConsortiumApi; +import org.folio.search.service.consortium.ConsortiumCampusService; import org.folio.search.service.consortium.ConsortiumInstanceSearchService; import org.folio.search.service.consortium.ConsortiumInstanceService; import org.folio.search.service.consortium.ConsortiumLocationService; @@ -45,6 +47,7 @@ public class SearchConsortiumController implements SearchConsortiumApi { private final ConsortiumInstanceService instanceService; private final ConsortiumLocationService locationService; private final ConsortiumInstanceSearchService searchService; + private final ConsortiumCampusService campusService; @Override public ResponseEntity getConsortiumHoldings(String tenantHeader, String instanceId, @@ -96,6 +99,21 @@ public ResponseEntity getConsortiumLocations(Strin .totalRecords(result.getTotalRecords())); } + @Override + public ResponseEntity getConsortiumCampuses(String tenantHeader, + String tenantId, + Integer limit, + Integer offset, + String sortBy, + SortOrder sortOrder) { + var result = campusService.fetchCampuses(tenantHeader, tenantId, limit, offset, sortBy, sortOrder); + + return ResponseEntity.ok(new + ConsortiumCampusCollection() + .campuses(result.getRecords()) + .totalRecords(result.getTotalRecords())); + } + @Override public ResponseEntity getConsortiumHolding(UUID id, String tenantHeader) { var tenant = verifyAndGetTenant(tenantHeader); diff --git a/src/main/java/org/folio/search/model/dto/locationunit/CampusDto.java b/src/main/java/org/folio/search/model/dto/locationunit/CampusDto.java index 3c9b87a69..619d44a0d 100644 --- a/src/main/java/org/folio/search/model/dto/locationunit/CampusDto.java +++ b/src/main/java/org/folio/search/model/dto/locationunit/CampusDto.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.With; import lombok.extern.jackson.Jacksonized; +import org.folio.search.domain.dto.Metadata; /** * Describes Campus object that comes from external channels. @@ -25,5 +26,7 @@ public class CampusDto { private String code; @JsonProperty("institutionId") private String institutionId; + @JsonProperty("metadata") + private Metadata metadata; } diff --git a/src/main/java/org/folio/search/repository/ConsortiumCampusRepository.java b/src/main/java/org/folio/search/repository/ConsortiumCampusRepository.java new file mode 100644 index 000000000..b1c07733e --- /dev/null +++ b/src/main/java/org/folio/search/repository/ConsortiumCampusRepository.java @@ -0,0 +1,79 @@ +package org.folio.search.repository; + +import static org.folio.search.utils.SearchUtils.TENANT_ID_FIELD_NAME; +import static org.folio.search.utils.SearchUtils.performExceptionalOperation; +import static org.opensearch.search.sort.SortOrder.ASC; +import static org.opensearch.search.sort.SortOrder.DESC; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.search.domain.dto.ConsortiumCampus; +import org.folio.search.domain.dto.SortOrder; +import org.folio.search.model.SearchResult; +import org.folio.search.service.converter.ElasticsearchDocumentConverter; +import org.jetbrains.annotations.NotNull; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.RequestOptions; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.SortBuilders; +import org.springframework.stereotype.Repository; + +@Log4j2 +@Repository +@RequiredArgsConstructor +public class ConsortiumCampusRepository { + + public static final String CAMPUS_INDEX = "campus"; + private static final String OPERATION_TYPE = "searchApi"; + private final IndexNameProvider indexNameProvider; + private final ElasticsearchDocumentConverter documentConverter; + + private final RestHighLevelClient client; + + public SearchResult fetchCampuses(String tenantHeader, + String tenantId, + Integer limit, + Integer offset, + String sortBy, + SortOrder sortOrder) { + + var sourceBuilder = getSearchSourceBuilder(tenantId, limit, offset, sortBy, sortOrder); + var response = search(sourceBuilder, tenantHeader); + return documentConverter.convertToSearchResult(response, ConsortiumCampus.class); + } + + @NotNull + private static SearchSourceBuilder getSearchSourceBuilder(String tenantId, + Integer limit, + Integer offset, + String sortBy, + SortOrder sortOrder) { + var sourceBuilder = new SearchSourceBuilder(); + Optional.ofNullable(tenantId) + .ifPresent(id -> sourceBuilder + .query(QueryBuilders + .termQuery(TENANT_ID_FIELD_NAME, id))); + + return sourceBuilder + .from(offset) + .sort(SortBuilders + .fieldSort(sortBy) + .order(sortOrder == SortOrder.DESC ? DESC : ASC)) + .size(limit); + } + + private SearchResponse search(SearchSourceBuilder sourceBuilder, String tenantHeader) { + var index = indexNameProvider.getIndexName(CAMPUS_INDEX, tenantHeader); + var searchRequest = new SearchRequest(index); + + searchRequest.source(sourceBuilder); + + return performExceptionalOperation(() -> client.search(searchRequest, + RequestOptions.DEFAULT), index, OPERATION_TYPE); + } + +} diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java b/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java new file mode 100644 index 000000000..3bbc28983 --- /dev/null +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java @@ -0,0 +1,46 @@ +package org.folio.search.service.consortium; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.search.domain.dto.ConsortiumCampus; +import org.folio.search.domain.dto.SortOrder; +import org.folio.search.model.SearchResult; +import org.folio.search.repository.ConsortiumCampusRepository; +import org.springframework.stereotype.Service; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class ConsortiumCampusService { + + public static final String ID = "id"; + public static final String NAME = "name"; + public static final String TENANT_ID = "tenantId"; + private final ConsortiumCampusRepository repository; + private final ConsortiumTenantExecutor executor; + + public SearchResult fetchCampuses(String tenantHeader, + String tenantId, + Integer limit, + Integer offset, + String sortBy, + SortOrder sortOrder) { + log.info("fetching consortium campuses for tenant: {}, tenantId: {}, sortBy: {}", + tenantHeader, + tenantId, + sortBy); + + validateSortByValue(sortBy); + + return executor.execute( + tenantHeader, + () -> repository.fetchCampuses(tenantHeader, tenantId, limit, offset, sortBy, sortOrder)); + } + + private void validateSortByValue(String sortBy) { + if (!(NAME.equals(sortBy) || ID.equals(sortBy) || TENANT_ID.equals(sortBy))) { + throw new IllegalArgumentException("Invalid sortBy value: " + sortBy); + } + } + +} diff --git a/src/main/resources/model/campus.json b/src/main/resources/model/campus.json index ef5034a9c..2d16ad210 100644 --- a/src/main/resources/model/campus.json +++ b/src/main/resources/model/campus.json @@ -22,6 +22,31 @@ }, "institutionId": { "index": "keyword_lowercase" + }, + "metadata": { + "type": "object", + "properties": { + "createdDate": { + "searchTypes": "filter", + "index": "date" + }, + "createdByUserId": { + "index": "keyword_lowercase" + }, + "createdByUsername": { + "index": "keyword_lowercase" + }, + "updatedDate": { + "searchTypes": "filter", + "index": "date" + }, + "updatedByUserId": { + "index": "keyword_lowercase" + }, + "updatedByUsername": { + "index": "keyword_lowercase" + } + } } } } diff --git a/src/main/resources/swagger.api/mod-search.yaml b/src/main/resources/swagger.api/mod-search.yaml index 71390f7e3..7b626d59c 100644 --- a/src/main/resources/swagger.api/mod-search.yaml +++ b/src/main/resources/swagger.api/mod-search.yaml @@ -76,6 +76,9 @@ paths: /search/consortium/locations: $ref: 'paths/search-consortium/search-consortium-locations.yaml' + /search/consortium/campuses: + $ref: 'paths/search-consortium/search-consortium-campuses.yaml' + /search/consortium/batch/items: $ref: 'paths/search-consortium/search-consortium-batch-items.yaml' diff --git a/src/main/resources/swagger.api/parameters/consortium-campuses-limit-param.yaml b/src/main/resources/swagger.api/parameters/consortium-campuses-limit-param.yaml new file mode 100644 index 000000000..aa4edf38b --- /dev/null +++ b/src/main/resources/swagger.api/parameters/consortium-campuses-limit-param.yaml @@ -0,0 +1,8 @@ +in: query +name: limit +description: Limit the number of elements returned in the response. +schema: + type: integer + minimum: 0 + maximum: 1000 + default: 1000 diff --git a/src/main/resources/swagger.api/parameters/sort-by-campus-name-param.yaml b/src/main/resources/swagger.api/parameters/sort-by-campus-name-param.yaml new file mode 100644 index 000000000..3504847bb --- /dev/null +++ b/src/main/resources/swagger.api/parameters/sort-by-campus-name-param.yaml @@ -0,0 +1,13 @@ +in: query +name: sortBy +description: | + Defines a field to sort by. + Possible values: + - id + - tenantId + - institutionId + - name +required: false +schema: + type: string + default: name diff --git a/src/main/resources/swagger.api/paths/search-consortium/search-consortium-campuses.yaml b/src/main/resources/swagger.api/paths/search-consortium/search-consortium-campuses.yaml new file mode 100644 index 000000000..1990bc832 --- /dev/null +++ b/src/main/resources/swagger.api/paths/search-consortium/search-consortium-campuses.yaml @@ -0,0 +1,24 @@ +get: + operationId: getConsortiumCampuses + summary: Get Consortium Campuses + description: Get a list of campuses (only for consortium environment) + tags: + - search-consortium + parameters: + - $ref: '../../parameters/tenant-id-query-param.yaml' + - $ref: '../../parameters/consortium-campuses-limit-param.yaml' + - $ref: '../../parameters/offset-param.yaml' + - $ref: '../../parameters/sort-by-campus-name-param.yaml' + - $ref: '../../parameters/sort-order-param.yaml' + - $ref: '../../parameters/x-okapi-tenant-header.yaml' + responses: + '200': + description: List of campuses + content: + application/json: + schema: + $ref: '../../schemas/entity/consortiumCampusCollection.yaml' + '400': + $ref: '../../responses/badRequestResponse.yaml' + '500': + $ref: '../../responses/internalServerErrorResponse.yaml' diff --git a/src/main/resources/swagger.api/schemas/entity/consortiumCampus.yaml b/src/main/resources/swagger.api/schemas/entity/consortiumCampus.yaml new file mode 100644 index 000000000..26103e4fe --- /dev/null +++ b/src/main/resources/swagger.api/schemas/entity/consortiumCampus.yaml @@ -0,0 +1,16 @@ +type: object +properties: + id: + description: Campus ID + type: string + tenantId: + description: Tenant ID of the Campus + type: string + name: + description: Campus name + type: string + institutionId: + description: The UUID of the institution, the first-level location unit, this (shelf) campus belongs to. + type: string + metadata: + $ref: "../dto/common/metadata.yaml" diff --git a/src/main/resources/swagger.api/schemas/entity/consortiumCampusCollection.yaml b/src/main/resources/swagger.api/schemas/entity/consortiumCampusCollection.yaml new file mode 100644 index 000000000..d9d10505a --- /dev/null +++ b/src/main/resources/swagger.api/schemas/entity/consortiumCampusCollection.yaml @@ -0,0 +1,8 @@ +type: object +properties: + campuses: + type: array + items: + $ref: './consortiumCampus.yaml' + totalRecords: + type: integer diff --git a/src/test/java/org/folio/search/controller/ConsortiumSearchCampusesIT.java b/src/test/java/org/folio/search/controller/ConsortiumSearchCampusesIT.java new file mode 100644 index 000000000..f291af9d4 --- /dev/null +++ b/src/test/java/org/folio/search/controller/ConsortiumSearchCampusesIT.java @@ -0,0 +1,115 @@ +package org.folio.search.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.awaitility.Durations.ONE_MINUTE; +import static org.awaitility.Durations.ONE_SECOND; +import static org.folio.search.domain.dto.ResourceEventType.CREATE; +import static org.folio.search.model.Pair.pair; +import static org.folio.search.sample.SampleCampuses.getCampusesSampleAsMap; +import static org.folio.search.support.base.ApiEndpoints.consortiumCampusesSearchPath; +import static org.folio.search.utils.SearchUtils.CAMPUS_RESOURCE; +import static org.folio.search.utils.TestConstants.CENTRAL_TENANT_ID; +import static org.folio.search.utils.TestConstants.MEMBER_TENANT_ID; +import static org.folio.search.utils.TestConstants.inventoryCampusTopic; +import static org.folio.search.utils.TestUtils.kafkaResourceEvent; +import static org.folio.search.utils.TestUtils.parseResponse; + +import java.util.List; +import java.util.stream.Stream; +import org.assertj.core.groups.Tuple; +import org.folio.search.domain.dto.ConsortiumCampus; +import org.folio.search.domain.dto.ConsortiumCampusCollection; +import org.folio.search.model.Pair; +import org.folio.search.support.base.BaseConsortiumIntegrationTest; +import org.folio.spring.testing.type.IntegrationTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.util.StringUtils; + +@IntegrationTest +class ConsortiumSearchCampusesIT extends BaseConsortiumIntegrationTest { + + private static final int EXPECTED_WITH_TWO_TENANTS = 18; + private static final int EXPECTED_WITH_SINGLE_TENANT = 9; + + @BeforeAll + static void prepare() { + setUpTenant(CENTRAL_TENANT_ID); + setUpTenant(MEMBER_TENANT_ID); + saveCampusRecords(); + } + + @AfterAll + static void cleanUp() { + removeTenant(MEMBER_TENANT_ID); + removeTenant(CENTRAL_TENANT_ID); + } + + @Test + void doGetConsortiumCampuses_returns200AndRecords() { + List> queryParams = List.of(); + + var result = doGet(consortiumCampusesSearchPath(queryParams), MEMBER_TENANT_ID); + var actual = parseResponse(result, ConsortiumCampusCollection.class); + + assertThat(actual.getCampuses()).hasSize(EXPECTED_WITH_TWO_TENANTS); + + assertThat(actual.getTotalRecords()).isEqualTo(EXPECTED_WITH_TWO_TENANTS); + + assertThat(actual.getCampuses()) + .filteredOn(location -> location.getTenantId().equals(MEMBER_TENANT_ID)) + .hasSize(EXPECTED_WITH_SINGLE_TENANT); + + assertThat(actual.getCampuses()) + .filteredOn(location -> location.getTenantId().equals(CENTRAL_TENANT_ID)) + .hasSize(EXPECTED_WITH_SINGLE_TENANT); + + assertThat(actual.getCampuses()) + .extracting(ConsortiumCampus::getId, ConsortiumCampus::getName, ConsortiumCampus::getTenantId, + ConsortiumCampus::getInstitutionId) + .map(Tuple::toList) + .matches(locations -> locations.stream().allMatch(obj -> StringUtils.isNotBlank(obj.toString()))); + + assertThat(actual.getCampuses()) + .map(ConsortiumCampus::getMetadata) + .filteredOn(metadata -> metadata.getCreatedDate() != null && metadata.getUpdatedDate() != null) + .hasSize(EXPECTED_WITH_TWO_TENANTS); + } + + @Test + void doGetConsortiumCampuses_returns200AndRecords_withAllQueryParams() { + List> queryParams = List.of( + pair("tenantId", "consortium"), + pair("limit", "5"), + pair("offset", "0"), + pair("sortBy", "name"), + pair("sortOrder", "asc") + ); + + var result = doGet(consortiumCampusesSearchPath(queryParams), CENTRAL_TENANT_ID); + var actual = parseResponse(result, ConsortiumCampusCollection.class); + + assertThat(actual.getCampuses()).hasSize(5); + assertThat(actual.getTotalRecords()).isEqualTo(EXPECTED_WITH_SINGLE_TENANT); + assertThat(actual.getCampuses().get(0).getTenantId()).isEqualTo(CENTRAL_TENANT_ID); + // check sortBy name + assertThat(actual.getCampuses().get(0).getName()).isEqualTo("My campus 1"); + assertThat(actual.getCampuses().get(1).getName()).isEqualTo("My campus 2"); + } + + private static void saveCampusRecords() { + getCampusesSampleAsMap().stream() + .flatMap(campus -> Stream.of( + kafkaResourceEvent(CENTRAL_TENANT_ID, CREATE, campus, null), + kafkaResourceEvent(MEMBER_TENANT_ID, CREATE, campus, null))) + .forEach(event -> kafkaTemplate.send(inventoryCampusTopic(event.getTenant()), event)); + + await().atMost(ONE_MINUTE).pollInterval(ONE_SECOND).untilAsserted(() -> { + var totalHits = countIndexDocument(CAMPUS_RESOURCE, CENTRAL_TENANT_ID); + + assertThat(totalHits).isEqualTo(EXPECTED_WITH_TWO_TENANTS); + }); + } +} diff --git a/src/test/java/org/folio/search/repository/ConsortiumCampusRepositoryTest.java b/src/test/java/org/folio/search/repository/ConsortiumCampusRepositoryTest.java new file mode 100644 index 000000000..6f661d472 --- /dev/null +++ b/src/test/java/org/folio/search/repository/ConsortiumCampusRepositoryTest.java @@ -0,0 +1,155 @@ +package org.folio.search.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.folio.search.repository.ConsortiumCampusRepository.CAMPUS_INDEX; +import static org.folio.search.utils.TestConstants.INDEX_NAME; +import static org.folio.search.utils.TestConstants.MEMBER_TENANT_ID; +import static org.folio.search.utils.TestConstants.TENANT_ID; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.opensearch.client.RequestOptions.DEFAULT; + +import java.io.IOException; +import org.folio.search.domain.dto.ConsortiumCampus; +import org.folio.search.model.SearchResult; +import org.folio.search.service.converter.ElasticsearchDocumentConverter; +import org.folio.spring.testing.type.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.index.query.TermQueryBuilder; +import org.opensearch.search.sort.FieldSortBuilder; +import org.opensearch.search.sort.SortOrder; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class ConsortiumCampusRepositoryTest { + + @Mock + private IndexNameProvider indexNameProvider; + @Mock + private ElasticsearchDocumentConverter documentConverter; + @Mock + private RestHighLevelClient client; + @InjectMocks + private ConsortiumCampusRepository repository; + + @Captor + private ArgumentCaptor requestCaptor; + + @BeforeEach + void setUp() { + lenient().when(indexNameProvider.getIndexName(CAMPUS_INDEX, TENANT_ID)).thenReturn(INDEX_NAME); + } + + @Test + void fetchCampuses_positive() throws IOException { + var limit = 123; + var offset = 321; + var sortBy = "test"; + var searchResponse = mock(SearchResponse.class); + var searchResult = Mockito.>mock(); + + when(client.search(requestCaptor.capture(), eq(DEFAULT))).thenReturn(searchResponse); + when(documentConverter.convertToSearchResult(searchResponse, ConsortiumCampus.class)).thenReturn(searchResult); + + var actual = repository.fetchCampuses(TENANT_ID, null, limit, offset, sortBy, null); + + assertThat(actual).isEqualTo(searchResult); + + assertThat(requestCaptor.getValue()) + .matches(request -> request.indices().length == 1 && request.indices()[0].equals(INDEX_NAME)) + .satisfies(request -> { + var source = request.source(); + assertThat(source.size()).isEqualTo(limit); + assertThat(source.from()).isEqualTo(offset); + assertThat(source.sorts()).hasSize(1); + assertThat(source.sorts().get(0)).isInstanceOf(FieldSortBuilder.class); + + var sort = (FieldSortBuilder) source.sorts().get(0); + assertThat(sort.getFieldName()).isEqualTo(sortBy); + assertThat(sort.order()).isEqualTo(SortOrder.ASC); + + assertThat(source.query()).isNull(); + }); + } + + @Test + void fetchCampuses_positive_withTenantFilter() throws IOException { + var limit = 123; + var offset = 321; + var sortBy = "test"; + var searchResponse = mock(SearchResponse.class); + var searchResult = Mockito.>mock(); + + when(client.search(requestCaptor.capture(), eq(DEFAULT))).thenReturn(searchResponse); + when(documentConverter.convertToSearchResult(searchResponse, ConsortiumCampus.class)).thenReturn(searchResult); + + var actual = repository.fetchCampuses(TENANT_ID, MEMBER_TENANT_ID, limit, offset, sortBy, null); + + assertThat(actual).isEqualTo(searchResult); + + assertThat(requestCaptor.getValue()) + .matches(request -> request.indices().length == 1 && request.indices()[0].equals(INDEX_NAME)) + .satisfies(request -> { + var source = request.source(); + assertThat(source.size()).isEqualTo(limit); + assertThat(source.from()).isEqualTo(offset); + assertThat(source.sorts()).hasSize(1); + assertThat(source.sorts().get(0)).isInstanceOf(FieldSortBuilder.class); + + var sort = (FieldSortBuilder) source.sorts().get(0); + assertThat(sort.getFieldName()).isEqualTo(sortBy); + assertThat(sort.order()).isEqualTo(SortOrder.ASC); + assertThat(source.query()).isInstanceOf(TermQueryBuilder.class); + + var query = (TermQueryBuilder) source.query(); + assertThat(query.fieldName()).isEqualTo("tenantId"); + assertThat(query.value()).isEqualTo(MEMBER_TENANT_ID); + }); + } + + @Test + void fetchCampuses_positive_sortDesc() throws IOException { + var limit = 123; + var offset = 321; + var sortBy = "test"; + var searchResponse = mock(SearchResponse.class); + var searchResult = Mockito.>mock(); + + when(client.search(requestCaptor.capture(), eq(DEFAULT))).thenReturn(searchResponse); + when(documentConverter.convertToSearchResult(searchResponse, ConsortiumCampus.class)).thenReturn(searchResult); + + var actual = repository.fetchCampuses(TENANT_ID, null, limit, offset, sortBy, + org.folio.search.domain.dto.SortOrder.DESC); + + assertThat(actual).isEqualTo(searchResult); + + assertThat(requestCaptor.getValue()) + .matches(request -> request.indices().length == 1 && request.indices()[0].equals(INDEX_NAME)) + .satisfies(request -> { + var source = request.source(); + assertThat(source.size()).isEqualTo(limit); + assertThat(source.from()).isEqualTo(offset); + assertThat(source.sorts()).hasSize(1); + assertThat(source.sorts().get(0)).isInstanceOf(FieldSortBuilder.class); + + var sort = (FieldSortBuilder) source.sorts().get(0); + assertThat(sort.getFieldName()).isEqualTo(sortBy); + assertThat(sort.order()).isEqualTo(SortOrder.DESC); + assertThat(source.query()).isNull(); + }); + } + +} diff --git a/src/test/java/org/folio/search/sample/SampleCampuses.java b/src/test/java/org/folio/search/sample/SampleCampuses.java new file mode 100644 index 000000000..b7b1fa15d --- /dev/null +++ b/src/test/java/org/folio/search/sample/SampleCampuses.java @@ -0,0 +1,20 @@ +package org.folio.search.sample; + +import static org.folio.search.utils.TestUtils.readJsonFromFile; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SampleCampuses { + + private static final List> CAMPUSES_RECORD_AS_MAP = + readJsonFromFile("/samples/campus-sample/campuses.json", new TypeReference<>() { }); + + public static List> getCampusesSampleAsMap() { + return CAMPUSES_RECORD_AS_MAP; + } +} diff --git a/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java b/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java new file mode 100644 index 000000000..21eb11dbf --- /dev/null +++ b/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java @@ -0,0 +1,96 @@ +package org.folio.search.service.consortium; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import org.folio.search.domain.dto.ConsortiumCampus; +import org.folio.search.domain.dto.SortOrder; +import org.folio.search.model.SearchResult; +import org.folio.search.repository.ConsortiumCampusRepository; +import org.folio.spring.testing.type.UnitTest; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith(MockitoExtension.class) +public class ConsortiumCampusServiceTest { + + public static final String ID = UUID.randomUUID().toString(); + public static final String CAMPUS_NAME = "campus name"; + public static final String CONSORTIUM_TENANT = "consortium"; + + public static final String SORT_BY_ID = "id"; + public static final String SORT_BT_NAME = "name"; + public static final String SORT_BY_TENANT_ID = "tenantId"; + + @Mock + private ConsortiumCampusRepository repository; + @Mock + private ConsortiumTenantExecutor executor; + + @InjectMocks + private ConsortiumCampusService service; + + @ParameterizedTest + @SuppressWarnings("unchecked") + @ValueSource(strings = {SORT_BY_ID, SORT_BT_NAME, SORT_BY_TENANT_ID}) + void fetchCampuses_ValidSortBy(String sortBy) { + var tenantHeader = CONSORTIUM_TENANT; + var tenantId = CONSORTIUM_TENANT; + var sortOrder = SortOrder.ASC; + var limit = 10; + var offset = 0; + var searchResult = prepareSearchResult(); + + when(repository.fetchCampuses(tenantHeader, tenantId, limit, offset, sortBy, sortOrder)) + .thenReturn(searchResult); + when(executor.execute(eq(tenantId), any(Supplier.class))) + .thenAnswer(invocation -> ((Supplier) invocation.getArgument(1)).get()); + + var actual = service.fetchCampuses(tenantHeader, tenantId, limit, offset, sortBy, sortOrder); + + assertThat(actual).isEqualTo(searchResult); + verify(repository).fetchCampuses(tenantHeader, tenantId, limit, offset, sortBy, sortOrder); + verify(executor).execute(eq(tenantId), any(Supplier.class)); + } + + @Test + void fetchCampuses_InvalidSortBy() { + var sortOrder = SortOrder.ASC; + var limit = 10; + var offset = 0; + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + var actual = service.fetchCampuses(CONSORTIUM_TENANT, CONSORTIUM_TENANT, limit, offset, "invalid", sortOrder); + + assertThat(actual).isEqualTo(null); + }); + } + + @NotNull + private static SearchResult prepareSearchResult() { + var campus = new ConsortiumCampus() + .id(ID) + .name(CAMPUS_NAME) + .tenantId(CONSORTIUM_TENANT) + .institutionId(ID); + + var searchResult = new SearchResult(); + searchResult.records(List.of(campus)); + + return searchResult; + } +} diff --git a/src/test/java/org/folio/search/support/base/ApiEndpoints.java b/src/test/java/org/folio/search/support/base/ApiEndpoints.java index 5f8c14f3d..e2348b2cf 100644 --- a/src/test/java/org/folio/search/support/base/ApiEndpoints.java +++ b/src/test/java/org/folio/search/support/base/ApiEndpoints.java @@ -33,6 +33,14 @@ public static String consortiumLocationsSearchPath(List> qu return addQueryParams(consortiumLocationsSearchPath(), queryParams); } + public static String consortiumCampusesSearchPath() { + return "/search/consortium/campuses"; + } + + public static String consortiumCampusesSearchPath(List> queryParams) { + return addQueryParams(consortiumCampusesSearchPath(), queryParams); + } + public static String consortiumItemsSearchPath() { return "/search/consortium/items"; } diff --git a/src/test/java/org/folio/search/utils/TestConstants.java b/src/test/java/org/folio/search/utils/TestConstants.java index af37169f0..6e7b827e0 100644 --- a/src/test/java/org/folio/search/utils/TestConstants.java +++ b/src/test/java/org/folio/search/utils/TestConstants.java @@ -40,6 +40,7 @@ public class TestConstants { public static final String BIBFRAME_TOPIC = "search.bibframe"; public static final String CAMPUS_TOPIC = "inventory.campus"; public static final String INSTITUTION_TOPIC = "inventory.institution"; + public static final String LIBRARY_TOPIC = "inventory.library"; public static final String LOCAL_CN_TYPE = "6fd29f52-5c9c-44d0-b529-e9c5eb3a0aba"; public static final String FOLIO_CN_TYPE = "6e4d7565-b277-4dfa-8b7d-fbf306d9d0cd"; @@ -134,6 +135,10 @@ public static String inventoryInstitutionTopic(String tenantId) { return getTopicName(tenantId, INSTITUTION_TOPIC); } + public static String inventoryLibraryTopic(String tenantId) { + return getTopicName(tenantId, LIBRARY_TOPIC); + } + public static String indexName(String tenantId) { return String.join("_", ENV, INSTANCE_RESOURCE, tenantId); } diff --git a/src/test/resources/samples/campus-sample/campuses.json b/src/test/resources/samples/campus-sample/campuses.json new file mode 100644 index 000000000..7cbc9edc3 --- /dev/null +++ b/src/test/resources/samples/campus-sample/campuses.json @@ -0,0 +1,92 @@ +[ + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe21", + "name": "My campus 1", + "code": "MC1", + "campusId": "62cf76b7-cca5-4d33-9217-edf42ce1a848", + "metadata": { + "createdDate": "2024-06-25T05:28:41.306+00:00", + "updatedDate": "2024-06-25T05:28:41.306+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe22", + "name": "My campus 2", + "code": "MC2", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe23", + "name": "My campus 3", + "code": "MC3", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe24", + "name": "My campus 4", + "code": "MC4", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe25", + "name": "My campus 5", + "code": "MC5", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe26", + "name": "My campus 6", + "code": "MC6", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe27", + "name": "My campus 7", + "code": "MC7", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe28", + "name": "My campus 8", + "code": "MC8", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + }, + { + "id": "83891666-dcb6-4cd7-ad3a-f4b305abfe29", + "name": "My campus 9", + "code": "MC9", + "campusId": "470ff1dd-937a-4195-bf9e-06bcfcd135df", + "metadata": { + "createdDate": "2024-06-25T05:28:41.312+00:00", + "updatedDate": "2024-06-25T05:28:41.312+00:00" + } + } +] From 6a4c30bd93859fe3fcbd16f594dfa71962d7483b Mon Sep 17 00:00:00 2001 From: BKadirkhodjaev Date: Wed, 26 Jun 2024 16:11:27 +0500 Subject: [PATCH 2/3] [MSEARCH-773]. Fix sonar issues --- .../service/consortium/ConsortiumCampusServiceTest.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java b/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java index 21eb11dbf..259ca95ef 100644 --- a/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java +++ b/src/test/java/org/folio/search/service/consortium/ConsortiumCampusServiceTest.java @@ -73,11 +73,9 @@ void fetchCampuses_InvalidSortBy() { var limit = 10; var offset = 0; - Assertions.assertThrows(IllegalArgumentException.class, () -> { - var actual = service.fetchCampuses(CONSORTIUM_TENANT, CONSORTIUM_TENANT, limit, offset, "invalid", sortOrder); - - assertThat(actual).isEqualTo(null); - }); + Assertions.assertThrows(IllegalArgumentException.class, () -> + service.fetchCampuses(CONSORTIUM_TENANT, CONSORTIUM_TENANT, limit, offset, "invalid", sortOrder) + ); } @NotNull From 64d1f4d6895caad8c8b90207201d1c47df3f9009 Mon Sep 17 00:00:00 2001 From: BKadirkhodjaev Date: Wed, 26 Jun 2024 16:26:06 +0500 Subject: [PATCH 3/3] [MSEARCH-773]. Simplify sortBy validation --- .../search/service/consortium/ConsortiumCampusService.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java b/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java index 3bbc28983..3fa1a6151 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumCampusService.java @@ -1,5 +1,6 @@ package org.folio.search.service.consortium; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.search.domain.dto.ConsortiumCampus; @@ -13,9 +14,7 @@ @RequiredArgsConstructor public class ConsortiumCampusService { - public static final String ID = "id"; - public static final String NAME = "name"; - public static final String TENANT_ID = "tenantId"; + private static final Set VALID_SORT_BY = Set.of("id", "name", "tenantId"); private final ConsortiumCampusRepository repository; private final ConsortiumTenantExecutor executor; @@ -38,7 +37,7 @@ public SearchResult fetchCampuses(String tenantHeader, } private void validateSortByValue(String sortBy) { - if (!(NAME.equals(sortBy) || ID.equals(sortBy) || TENANT_ID.equals(sortBy))) { + if (!VALID_SORT_BY.contains(sortBy)) { throw new IllegalArgumentException("Invalid sortBy value: " + sortBy); } }