diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 7e1bdf76..0d5895d2 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -752,27 +752,58 @@ { "methods": ["GET"], "pathPattern": "/finance/fund-update-logs", - "permissionsRequired": ["finance.fund-update-logs.collection.get"] + "permissionsRequired": ["finance.fund-update-logs.collection.get"], + "modulePermissions": [ + "finance-storage.fund-update-logs.collection.get" + ] }, { "methods": ["GET"], "pathPattern": "/finance/fund-update-logs/{id}", - "permissionsRequired": ["finance.fund-update-logs.item.get"] + "permissionsRequired": ["finance.fund-update-logs.item.get"], + "modulePermissions": [ + "finance-storage.fund-update-logs.item.get" + ] }, { "methods": ["POST"], "pathPattern": "/finance/fund-update-logs", - "permissionsRequired": ["finance.fund-update-logs.item.post"] + "permissionsRequired": ["finance.fund-update-logs.item.post"], + "modulePermissions": [ + "finance-storage.fund-update-logs.item.post" + ] }, { "methods": ["PUT"], "pathPattern": "/finance/fund-update-logs/{id}", - "permissionsRequired": ["finance.fund-update-logs.item.put"] + "permissionsRequired": ["finance.fund-update-logs.item.put"], + "modulePermissions": [ + "finance-storage.fund-update-logs.item.put" + ] }, { "methods": ["DELETE"], "pathPattern": "/finance/fund-update-logs/{id}", - "permissionsRequired": ["finance.fund-update-logs.item.delete"] + "permissionsRequired": ["finance.fund-update-logs.item.delete"], + "modulePermissions": [ + "finance-storage.fund-update-logs.item.delete" + ] + } + ] + }, + { + "id": "finance.finance-data", + "version": "1.0", + "handlers": [ + { + "methods": ["GET"], + "pathPattern": "/finance/finance-data", + "permissionsRequired": ["finance.finance-data.collection.get"], + "modulePermissions": [ + "finance-storage.finance-data.collection.get", + "acquisitions-units-storage.units.collection.get", + "acquisitions-units-storage.memberships.collection.get" + ] } ] }, @@ -853,6 +884,10 @@ { "id": "finance-storage.fund-update-logs", "version":"1.0" + }, + { + "id": "finance-storage.finance-data", + "version":"1.0" } ], "optional": [ @@ -1338,6 +1373,11 @@ "finance.fund-update-logs.item.delete" ] }, + { + "permissionName": "finance.finance-data.collection.get", + "displayName": "Finances - get finance data collection", + "description": "Get finance data collection" + }, { "permissionName": "finance.all", "displayName": "Finance module - all permissions", @@ -1357,7 +1397,8 @@ "finance.calculate-exchange.item.get", "finance.expense-classes.all", "finance.acquisitions-units-assignments.all", - "finance.fund-update-logs.all" + "finance.fund-update-logs.all", + "finance.finance-data.collection.get" ], "visible": false }, diff --git a/ramls/acq-models b/ramls/acq-models index 680fe63a..c06778a4 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit 680fe63a34fc7b25d9d59013a2758de76cf82b56 +Subproject commit c06778a484802db30023f1e5388a6d2972606ae1 diff --git a/ramls/finance-data.raml b/ramls/finance-data.raml new file mode 100644 index 00000000..6d807495 --- /dev/null +++ b/ramls/finance-data.raml @@ -0,0 +1,37 @@ +#%RAML 1.0 +title: Finance - finance data +version: v1 +protocols: [ HTTP, HTTPS ] +baseUri: https://github.com/folio-org/mod-finance + +documentation: + - title: Finance data APIs + content: This documents the API calls that can be made to manage finance data + +types: + errors: !include raml-util/schemas/errors.schema + fy-finance-data-collection: !include acq-models/mod-finance/schemas/fy_finance_data_collection.json + UUID: + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ + +traits: + pageable: !include raml-util/traits/pageable.raml + searchable: !include raml-util/traits/searchable.raml + validate: !include raml-util/traits/validation.raml + +resourceTypes: + collection-get: !include raml-util/rtypes/collection-get.raml + +/finance/finance-data: + type: + collection-get: + exampleCollection: !include acq-models/mod-finance/examples/fy_finance_data_collection.sample + schemaCollection: fy-finance-data-collection + get: + description: Get finance data + is: [ + searchable: { description: "with valid searchable fields: for example fiscalYearId", example: "[\"fiscalYearId\", \"7a4c4d30-3b63-4102-8e2d-3ee5792d7d02\", \"=\"]" }, + pageable + ] + diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java index 5edc9163..765aafb7 100644 --- a/src/main/java/org/folio/config/ServicesConfiguration.java +++ b/src/main/java/org/folio/config/ServicesConfiguration.java @@ -8,6 +8,7 @@ import org.folio.services.budget.RecalculateBudgetService; import org.folio.services.budget.CreateBudgetService; import org.folio.services.configuration.ConfigurationEntriesService; +import org.folio.services.financedata.FinanceDataService; import org.folio.services.fiscalyear.FiscalYearApiService; import org.folio.services.fiscalyear.FiscalYearService; import org.folio.services.fund.FundCodeExpenseClassesService; @@ -216,4 +217,9 @@ FundCodeExpenseClassesService fundCodeExpenseClassesService(BudgetService budget FundUpdateLogService fundUpdateLogService(RestClient restClient) { return new FundUpdateLogService(restClient); } + + @Bean + FinanceDataService financeDataService(RestClient restClient, AcqUnitsService acqUnitsService) { + return new FinanceDataService(restClient, acqUnitsService); + } } diff --git a/src/main/java/org/folio/rest/impl/FinanceDataApi.java b/src/main/java/org/folio/rest/impl/FinanceDataApi.java new file mode 100644 index 00000000..70b5c4ac --- /dev/null +++ b/src/main/java/org/folio/rest/impl/FinanceDataApi.java @@ -0,0 +1,37 @@ +package org.folio.rest.impl; + +import static io.vertx.core.Future.succeededFuture; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Context; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import java.util.Map; +import javax.ws.rs.core.Response; +import org.folio.rest.annotations.Validate; +import org.folio.rest.core.models.RequestContext; +import org.folio.rest.jaxrs.resource.FinanceFinanceData; +import org.folio.services.financedata.FinanceDataService; +import org.folio.spring.SpringContextUtil; +import org.springframework.beans.factory.annotation.Autowired; + +public class FinanceDataApi extends BaseApi implements FinanceFinanceData { + + @Autowired + private FinanceDataService financeDataService; + + public FinanceDataApi() { + SpringContextUtil.autowireDependencies(this, Vertx.currentContext()); + } + + @Override + @Validate + public void getFinanceFinanceData(String query, String totalRecords, int offset, int limit, + Map okapiHeaders, Handler> asyncResultHandler, + Context vertxContext) { + financeDataService.getFinanceDataWithAcqUnitsRestriction(query, offset, limit, + new RequestContext(vertxContext, okapiHeaders)) + .onSuccess(financeData -> asyncResultHandler.handle(succeededFuture(buildOkResponse(financeData)))) + .onFailure(fail -> handleErrorResponse(asyncResultHandler, fail)); + } +} diff --git a/src/main/java/org/folio/rest/util/ResourcePathResolver.java b/src/main/java/org/folio/rest/util/ResourcePathResolver.java index 3592624b..d3d00085 100644 --- a/src/main/java/org/folio/rest/util/ResourcePathResolver.java +++ b/src/main/java/org/folio/rest/util/ResourcePathResolver.java @@ -11,6 +11,7 @@ private ResourcePathResolver() { } public static final String BUDGETS_STORAGE = "budgets"; + public static final String FINANCE_DATA_STORAGE = "financeData"; public static final String FUNDS_STORAGE = "funds"; public static final String FUND_TYPES = "fundTypes"; public static final String FUND_UPDATE_LOGS = "fundUpdateLogs"; @@ -38,6 +39,7 @@ private ResourcePathResolver() { static { Map apis = new HashMap<>(); apis.put(BUDGETS_STORAGE, "/finance-storage/budgets"); + apis.put(FINANCE_DATA_STORAGE, "/finance-storage/finance-data"); apis.put(FUNDS_STORAGE, "/finance-storage/funds"); apis.put(FUND_TYPES, "/finance-storage/fund-types"); apis.put(FUND_UPDATE_LOGS, "/finance-storage/fund-update-logs"); diff --git a/src/main/java/org/folio/services/financedata/FinanceDataService.java b/src/main/java/org/folio/services/financedata/FinanceDataService.java new file mode 100644 index 00000000..8d68415f --- /dev/null +++ b/src/main/java/org/folio/services/financedata/FinanceDataService.java @@ -0,0 +1,39 @@ +package org.folio.services.financedata; + +import static org.folio.rest.util.HelperUtils.combineCqlExpressions; +import static org.folio.rest.util.ResourcePathResolver.FINANCE_DATA_STORAGE; +import static org.folio.rest.util.ResourcePathResolver.resourcesPath; + +import io.vertx.core.Future; +import org.apache.commons.lang3.StringUtils; +import org.folio.rest.core.RestClient; +import org.folio.rest.core.models.RequestContext; +import org.folio.rest.core.models.RequestEntry; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.services.protection.AcqUnitsService; + +public class FinanceDataService { + private final RestClient restClient; + private final AcqUnitsService acqUnitsService; + + public FinanceDataService(RestClient restClient, AcqUnitsService acqUnitsService) { + this.restClient = restClient; + this.acqUnitsService = acqUnitsService; + } + + public Future getFinanceDataWithAcqUnitsRestriction(String query, int offset, int limit, + RequestContext requestContext) { + return acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(requestContext) + .map(clause -> StringUtils.isEmpty(query) ? clause : combineCqlExpressions("and", clause, query)) + .compose(effectiveQuery -> getFinanceData(effectiveQuery, offset, limit, requestContext)); + } + + private Future getFinanceData(String query, int offset, int limit, RequestContext requestContext) { + var requestEntry = new RequestEntry(resourcesPath(FINANCE_DATA_STORAGE)) + .withOffset(offset) + .withLimit(limit) + .withQuery(query); + return restClient.get(requestEntry.buildEndpoint(), FyFinanceDataCollection.class, requestContext); + } +} + diff --git a/src/main/java/org/folio/services/protection/AcqUnitConstants.java b/src/main/java/org/folio/services/protection/AcqUnitConstants.java index 688c3aca..33ff47d2 100644 --- a/src/main/java/org/folio/services/protection/AcqUnitConstants.java +++ b/src/main/java/org/folio/services/protection/AcqUnitConstants.java @@ -2,7 +2,11 @@ public final class AcqUnitConstants { public static final String ACQUISITIONS_UNIT_IDS = "acqUnitIds"; + public static final String FD_FUND_ACQUISITIONS_UNIT_IDS = "fundAcqUnitIds"; // for finance data view table + public static final String FD_BUDGET_ACQUISITIONS_UNIT_IDS = "budgetAcqUnitIds"; // for finance data view table public static final String NO_ACQ_UNIT_ASSIGNED_CQL = "cql.allRecords=1 not " + ACQUISITIONS_UNIT_IDS + " <> []"; + public static final String NO_FD_FUND_UNIT_ASSIGNED_CQL = "cql.allRecords=1 not " + FD_FUND_ACQUISITIONS_UNIT_IDS + " <> []"; + public static final String NO_FD_BUDGET_UNIT_ASSIGNED_CQL = "cql.allRecords=1 not " + FD_BUDGET_ACQUISITIONS_UNIT_IDS + " <> []"; public static final String IS_DELETED_PROP = "isDeleted"; public static final String ALL_UNITS_CQL = IS_DELETED_PROP + "=*"; public static final String ACTIVE_UNITS_CQL = IS_DELETED_PROP + "==false"; diff --git a/src/main/java/org/folio/services/protection/AcqUnitsService.java b/src/main/java/org/folio/services/protection/AcqUnitsService.java index 76a6b4a1..1588d1de 100644 --- a/src/main/java/org/folio/services/protection/AcqUnitsService.java +++ b/src/main/java/org/folio/services/protection/AcqUnitsService.java @@ -7,8 +7,12 @@ import static org.folio.rest.util.ResourcePathResolver.resourcesPath; import static org.folio.services.protection.AcqUnitConstants.ACQUISITIONS_UNIT_IDS; import static org.folio.services.protection.AcqUnitConstants.ACTIVE_UNITS_CQL; +import static org.folio.services.protection.AcqUnitConstants.FD_BUDGET_ACQUISITIONS_UNIT_IDS; +import static org.folio.services.protection.AcqUnitConstants.FD_FUND_ACQUISITIONS_UNIT_IDS; import static org.folio.services.protection.AcqUnitConstants.IS_DELETED_PROP; import static org.folio.services.protection.AcqUnitConstants.NO_ACQ_UNIT_ASSIGNED_CQL; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_BUDGET_UNIT_ASSIGNED_CQL; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_FUND_UNIT_ASSIGNED_CQL; import java.util.List; import java.util.stream.Collectors; @@ -60,7 +64,31 @@ public Future buildAcqUnitsCqlClause(RequestContext requestContext) { if (ids.isEmpty()) { return NO_ACQ_UNIT_ASSIGNED_CQL; } - return String.format("%s or (%s)", convertIdsToCqlQuery(ids, ACQUISITIONS_UNIT_IDS, false), NO_ACQ_UNIT_ASSIGNED_CQL); + return String.format("%s or (%s)", + convertIdsToCqlQuery(ids, ACQUISITIONS_UNIT_IDS, false), + NO_ACQ_UNIT_ASSIGNED_CQL); + }); + } + + public Future buildAcqUnitsCqlClauseForFinanceData(RequestContext requestContext) { + return getAcqUnitIdsForSearch(requestContext) + .map(ids -> { + if (ids.isEmpty()) { + return String.format("(%s and %s)", NO_FD_FUND_UNIT_ASSIGNED_CQL, NO_FD_BUDGET_UNIT_ASSIGNED_CQL); + } + return String.format("(" + + "(%s and %s) or " + // Case 1: Both fund and budget have matching acqUnits + "(%s and %s) or " + // Case 2: Fund has matching acqUnit and budget is empty + "(%s and %s) or " + // Case 3: Fund is empty and budget has matching acqUnit + "(%s and %s))", // Case 4: Both fund and budget are empty + convertIdsToCqlQuery(ids, FD_FUND_ACQUISITIONS_UNIT_IDS, false), + convertIdsToCqlQuery(ids, FD_BUDGET_ACQUISITIONS_UNIT_IDS, false), + convertIdsToCqlQuery(ids, FD_FUND_ACQUISITIONS_UNIT_IDS, false), + NO_FD_BUDGET_UNIT_ASSIGNED_CQL, + NO_FD_FUND_UNIT_ASSIGNED_CQL, + convertIdsToCqlQuery(ids, FD_BUDGET_ACQUISITIONS_UNIT_IDS, false), + NO_FD_FUND_UNIT_ASSIGNED_CQL, + NO_FD_BUDGET_UNIT_ASSIGNED_CQL); }); } @@ -102,6 +130,6 @@ private Future> getOpenForReadAcqUnitIds(RequestContext requestCont log.debug("{} acq units with 'protectRead==false' are found: {}", ids.size(), StreamEx.of(ids).joining(", ")); } return ids; - }); + }); } } diff --git a/src/test/java/org/folio/ApiTestSuite.java b/src/test/java/org/folio/ApiTestSuite.java index 30a36f30..33e72b6c 100644 --- a/src/test/java/org/folio/ApiTestSuite.java +++ b/src/test/java/org/folio/ApiTestSuite.java @@ -12,6 +12,7 @@ import org.folio.rest.impl.EncumbrancesTest; import org.folio.rest.impl.EntitiesCrudBasicsTest; import org.folio.rest.impl.ExchangeTest; +import org.folio.rest.impl.FinanceDataApiTest; import org.folio.rest.impl.FiscalYearTest; import org.folio.rest.impl.FundCodeExpenseClassesApiTest; import org.folio.rest.impl.FundsApiTest; @@ -32,6 +33,7 @@ import org.folio.services.budget.BudgetServiceTest; import org.folio.services.budget.CreateBudgetServiceTest; import org.folio.services.budget.RecalculateBudgetServiceTest; +import org.folio.services.financedata.FinanceDataServiceTest; import org.folio.services.fiscalyear.FiscalYearApiServiceTest; import org.folio.services.fiscalyear.FiscalYearServiceTest; import org.folio.services.fund.FundCodeExpenseClassesServiceTest; @@ -244,4 +246,9 @@ class GroupServiceNested extends GroupServiceTest {} @Nested class RecalculateBudgetServiceTestNested extends RecalculateBudgetServiceTest {} + @Nested + class FinanceDataApiTestNested extends FinanceDataApiTest {} + + @Nested + class FinanceDataServiceTestNested extends FinanceDataServiceTest {} } diff --git a/src/test/java/org/folio/rest/impl/EntitiesCrudBasicsTest.java b/src/test/java/org/folio/rest/impl/EntitiesCrudBasicsTest.java index 5409013f..9e84afbf 100644 --- a/src/test/java/org/folio/rest/impl/EntitiesCrudBasicsTest.java +++ b/src/test/java/org/folio/rest/impl/EntitiesCrudBasicsTest.java @@ -41,7 +41,6 @@ import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsEqual.equalTo; -import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -265,7 +264,7 @@ void testGetRecordByIdNotFound(TestEntities testEntity) { @ParameterizedTest @MethodSource("getTestEntitiesWithPostEndpoint") - void testPostRecord(TestEntities testEntity) throws IOException { + void testPostRecord(TestEntities testEntity) { logger.info("=== Test create {} record ===", testEntity.name()); JsonObject record = testEntity.getMockObject(); @@ -293,7 +292,7 @@ record = JsonObject.mapFrom(t); @ParameterizedTest @MethodSource("getTestEntitiesWithPostEndpoint") - void testPostRecordServerError(TestEntities testEntity) throws IOException { + void testPostRecordServerError(TestEntities testEntity) { logger.info("=== Test create {} record - Internal Server Error ===", testEntity.name()); Headers headers = RestTestUtils.prepareHeaders(TestConfig.X_OKAPI_URL, ERROR_X_OKAPI_TENANT); diff --git a/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java new file mode 100644 index 00000000..b2e986d2 --- /dev/null +++ b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java @@ -0,0 +1,141 @@ +package org.folio.rest.impl; + +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static javax.ws.rs.core.Response.Status.OK; +import static org.folio.rest.util.ErrorCodes.GENERIC_ERROR_CODE; +import static org.folio.rest.util.RestTestUtils.verifyGet; +import static org.folio.rest.util.RestTestUtils.verifyGetWithParam; +import static org.folio.rest.util.TestConfig.autowireDependencies; +import static org.folio.rest.util.TestConfig.clearVertxContext; +import static org.folio.rest.util.TestConfig.initSpringContext; +import static org.folio.rest.util.TestConfig.isVerticleNotDeployed; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_BUDGET_UNIT_ASSIGNED_CQL; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_FUND_UNIT_ASSIGNED_CQL; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import io.vertx.core.Future; +import io.vertx.ext.web.handler.HttpException; +import org.folio.ApiTestSuite; +import org.folio.rest.core.models.RequestContext; +import org.folio.rest.jaxrs.model.Errors; +import org.folio.rest.jaxrs.model.FyFinanceData; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.services.financedata.FinanceDataService; +import org.folio.services.protection.AcqUnitsService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; + +public class FinanceDataApiTest { + + @Autowired + public FinanceDataService financeDataService; + @Autowired + public AcqUnitsService acqUnitsService; + + private static boolean runningOnOwn; + private static final String FINANCE_DATA_ENDPOINT = "/finance/finance-data"; + + @BeforeAll + static void init() throws InterruptedException, ExecutionException, TimeoutException { + if (isVerticleNotDeployed()) { + ApiTestSuite.before(); + runningOnOwn = true; + } + initSpringContext(FinanceDataApiTest.ContextConfiguration.class); + } + + @BeforeEach + void beforeEach() { + autowireDependencies(this); + } + + @AfterAll + static void afterAll() { + if (runningOnOwn) { + ApiTestSuite.after(); + } + clearVertxContext(); + } + + @AfterEach + void resetMocks() { + reset(financeDataService); + reset(acqUnitsService); + } + + @Test + void positive_testGetFinanceFinanceDataSuccess() { + var fiscalYearId = "123e4567-e89b-12d3-a456-426614174004"; + var financeDataCollection = new FyFinanceDataCollection() + .withFyFinanceData(List.of(new FyFinanceData().withFiscalYearId(fiscalYearId))) + .withTotalRecords(1); + String query = "fiscalYearId==" + fiscalYearId; + String notAcqUnitAssignedQuery = "(" + NO_FD_FUND_UNIT_ASSIGNED_CQL + " and " + NO_FD_BUDGET_UNIT_ASSIGNED_CQL + ")"; + int limit = 5; + int offset = 1; + + Map params = new HashMap<>(); + params.put("query", query); + params.put("limit", limit); + params.put("offset", offset); + + when(acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(any())).thenReturn(succeededFuture(notAcqUnitAssignedQuery)); + when(financeDataService.getFinanceDataWithAcqUnitsRestriction(anyString(), anyInt(), anyInt(), any())) + .thenReturn(succeededFuture(financeDataCollection)); + + var response = verifyGetWithParam(FINANCE_DATA_ENDPOINT, APPLICATION_JSON, OK.getStatusCode(), params) + .as(FyFinanceDataCollection.class); + + assertEquals(financeDataCollection, response); + verify(financeDataService).getFinanceDataWithAcqUnitsRestriction(eq(query), eq(offset), eq(limit), any(RequestContext.class)); + } + + @Test + void negative_testGetFinanceFinanceDataFailure() { + Future financeDataFuture = failedFuture(new HttpException(500, INTERNAL_SERVER_ERROR.getReasonPhrase())); + + when(financeDataService.getFinanceDataWithAcqUnitsRestriction(any(), anyInt(), anyInt(), any(RequestContext.class))) + .thenReturn(financeDataFuture); + + var errors = verifyGet(FINANCE_DATA_ENDPOINT, APPLICATION_JSON, INTERNAL_SERVER_ERROR.getStatusCode()).as(Errors.class); + + assertThat(errors.getErrors(), hasSize(1)); + assertThat(errors.getErrors().get(0).getCode(), is(GENERIC_ERROR_CODE.getCode())); + } + + static class ContextConfiguration { + + @Bean public FinanceDataService financeDataService() { + return mock(FinanceDataService.class); + } + + @Bean AcqUnitsService acqUnitsService() { + return mock(AcqUnitsService.class); + } + } +} diff --git a/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java b/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java new file mode 100644 index 00000000..136dad1b --- /dev/null +++ b/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java @@ -0,0 +1,109 @@ +package org.folio.services.financedata; + +import static io.vertx.core.Future.succeededFuture; +import static org.folio.rest.util.TestUtils.assertQueryContains; +import static org.folio.services.protection.AcqUnitConstants.NO_ACQ_UNIT_ASSIGNED_CQL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.folio.rest.core.RestClient; +import org.folio.rest.core.models.RequestContext; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.services.protection.AcqUnitsService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; + +@ExtendWith(VertxExtension.class) +public class FinanceDataServiceTest { + + @InjectMocks + private FinanceDataService financeDataService; + + @Mock + private RestClient restClient; + + @Mock + private AcqUnitsService acqUnitsService; + + private RequestContext requestContextMock; + private AutoCloseable closeable; + + @BeforeEach + void initMocks() { + closeable = MockitoAnnotations.openMocks(this); + Context context = Vertx.vertx().getOrCreateContext(); + Map okapiHeaders = new HashMap<>(); + okapiHeaders.put("x-okapi-url", "http://localhost:9130"); // Ensure this URL is correct + okapiHeaders.put("x-okapi-token", "token"); + okapiHeaders.put("x-okapi-tenant", "tenant"); + okapiHeaders.put("x-okapi-user-id", "userId"); + requestContextMock = new RequestContext(context, okapiHeaders); + } + + @AfterEach + void closeMocks() throws Exception { + closeable.close(); + } + + @Test + void positive_shouldGetFinanceDataWithAcqUnitsRestriction(VertxTestContext vertxTestContext) { + String query = "fiscalYearId==db9c1ad6-026e-4b1a-9a99-032f41e7099b"; + String acqUnitIdsQuery = "fundAcqUnitIds=(1ee4b4e5-d621-4e43-8a76-0d904b0f491b) and budgetAcqUnitIds=(1ee4b4e5-d621-4e43-8a76-0d904b0f491b) or (" + NO_ACQ_UNIT_ASSIGNED_CQL + ")"; + String expectedQuery = "(" + acqUnitIdsQuery + ") and (" + query + ")"; + int offset = 0; + int limit = 10; + FyFinanceDataCollection fyFinanceDataCollection = new FyFinanceDataCollection(); + + when(acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(any())).thenReturn(succeededFuture(acqUnitIdsQuery)); + when(restClient.get(anyString(), eq(FyFinanceDataCollection.class), any())).thenReturn(succeededFuture(fyFinanceDataCollection)); + + var future = financeDataService.getFinanceDataWithAcqUnitsRestriction(query, offset, limit, requestContextMock); + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + verify(restClient).get(assertQueryContains(expectedQuery), eq(FyFinanceDataCollection.class), eq(requestContextMock)); + vertxTestContext.completeNow(); + }); + } + + @Test + void negative_shouldReturnEmptyCollectionWhenFinanceDataNotFound(VertxTestContext vertxTestContext) { + String query = "fiscalYearId==non-existent-id"; + String noFdUnitAssignedCql = "cql.allRecords=1 not fundAcqUnitIds <> [] and cql.allRecords=1 not budgetAcqUnitIds <> []"; + String expectedQuery = "(" + noFdUnitAssignedCql + ") and (" + query + ")"; + int offset = 0; + int limit = 10; + FyFinanceDataCollection emptyCollection = new FyFinanceDataCollection().withTotalRecords(0); + + when(acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(any())).thenReturn(succeededFuture(noFdUnitAssignedCql)); + when(restClient.get(anyString(), eq(FyFinanceDataCollection.class), any())).thenReturn(succeededFuture(emptyCollection)); + + var future = financeDataService.getFinanceDataWithAcqUnitsRestriction(query, offset, limit, requestContextMock); + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + assertEquals(0, (int) result.result().getTotalRecords()); + verify(restClient).get(assertQueryContains(expectedQuery), eq(FyFinanceDataCollection.class), eq(requestContextMock)); + vertxTestContext.completeNow(); + }); + } + +} diff --git a/src/test/java/org/folio/services/protection/AcqUnitsServiceTest.java b/src/test/java/org/folio/services/protection/AcqUnitsServiceTest.java index 705a90df..f461b8b2 100644 --- a/src/test/java/org/folio/services/protection/AcqUnitsServiceTest.java +++ b/src/test/java/org/folio/services/protection/AcqUnitsServiceTest.java @@ -7,6 +7,8 @@ import static org.folio.rest.util.TestConstants.X_OKAPI_TOKEN; import static org.folio.rest.util.TestConstants.X_OKAPI_USER_ID; import static org.folio.services.protection.AcqUnitConstants.NO_ACQ_UNIT_ASSIGNED_CQL; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_BUDGET_UNIT_ASSIGNED_CQL; +import static org.folio.services.protection.AcqUnitConstants.NO_FD_FUND_UNIT_ASSIGNED_CQL; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,6 +31,7 @@ import org.folio.rest.acq.model.finance.AcquisitionsUnitMembershipCollection; import org.folio.rest.core.RestClient; import org.folio.rest.core.models.RequestContext; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -52,9 +55,11 @@ public class AcqUnitsServiceTest { @Mock private RestClient restClient; + private AutoCloseable closeable; + @BeforeEach public void initMocks() { - MockitoAnnotations.openMocks(this); + closeable = MockitoAnnotations.openMocks(this); Context context = Vertx.vertx().getOrCreateContext(); Map okapiHeaders = new HashMap<>(); okapiHeaders.put(OKAPI_URL, "http://localhost:" + mockPort); @@ -64,6 +69,11 @@ public void initMocks() { requestContext = new RequestContext(context, okapiHeaders); } + @AfterEach + void clearContext() throws Exception { + closeable.close(); + } + @Test void testShouldUseRestClientWhenRetrieveAcqUnitByNotEmptyQuery(VertxTestContext vertxTestContext) { //Given @@ -164,4 +174,62 @@ void testShouldBuildCqlClauseWhenIdsIsNotEmpty(VertxTestContext vertxTestContext }); } + + @Test + void testShouldBuildCqlClauseForFinanceDataWhenIdsIsEmpty(VertxTestContext vertxTestContext) { + var units = new AcquisitionsUnitCollection() + .withAcquisitionsUnits(Collections.emptyList()).withTotalRecords(0); + var members = new AcquisitionsUnitMembershipCollection() + .withAcquisitionsUnitMemberships(Collections.emptyList()).withTotalRecords(0); + var expectedQuery = "(" + NO_FD_FUND_UNIT_ASSIGNED_CQL + " and " + NO_FD_BUDGET_UNIT_ASSIGNED_CQL + ")"; + + doReturn(succeededFuture(units)).when(restClient).get(anyString(), eq(AcquisitionsUnitCollection.class), eq(requestContext)); + doReturn(succeededFuture(members)).when(acqUnitMembershipsService).getAcquisitionsUnitsMemberships(anyString(), anyInt(), anyInt(), eq(requestContext)); + + var future = acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + + var actClause = result.result(); + assertThat(actClause, equalTo(expectedQuery)); + verify(restClient).get(anyString(), eq(AcquisitionsUnitCollection.class), eq(requestContext)); + verify(acqUnitMembershipsService).getAcquisitionsUnitsMemberships("userId==" + X_OKAPI_USER_ID.getValue(), 0, Integer.MAX_VALUE, requestContext); + + vertxTestContext.completeNow(); + }); + } + + @Test + void testShouldBuildCqlClauseForFinanceDataWhenIdsIsNotEmpty(VertxTestContext vertxTestContext) { + var unitId = UUID.randomUUID().toString(); + var units = new AcquisitionsUnitCollection() + .withAcquisitionsUnits(Collections.emptyList()).withTotalRecords(0); + var memberId = UUID.randomUUID().toString(); + var members = new AcquisitionsUnitMembershipCollection() + .withAcquisitionsUnitMemberships(List.of(new AcquisitionsUnitMembership().withAcquisitionsUnitId(unitId).withId(memberId))) + .withTotalRecords(1); + var expectedQuery = "((fundAcqUnitIds=(" + unitId + ") and budgetAcqUnitIds=(" + unitId + ")) or " + + "(fundAcqUnitIds=(" + unitId + ") and cql.allRecords=1 not budgetAcqUnitIds <> []) or " + + "(cql.allRecords=1 not fundAcqUnitIds <> [] and budgetAcqUnitIds=(" + unitId + ")) or " + + "(cql.allRecords=1 not fundAcqUnitIds <> [] and cql.allRecords=1 not budgetAcqUnitIds <> []))"; + + doReturn(succeededFuture(units)).when(restClient).get(anyString(), eq(AcquisitionsUnitCollection.class), eq(requestContext)); + doReturn(succeededFuture(members)).when(acqUnitMembershipsService).getAcquisitionsUnitsMemberships(anyString(), anyInt(), anyInt(), eq(requestContext)); + + var future = acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + + var actClause = result.result(); + assertThat(actClause, equalTo(expectedQuery)); + verify(restClient).get(anyString(), eq(AcquisitionsUnitCollection.class), eq(requestContext)); + verify(acqUnitMembershipsService).getAcquisitionsUnitsMemberships("userId==" + X_OKAPI_USER_ID.getValue(), 0, Integer.MAX_VALUE, requestContext); + + vertxTestContext.completeNow(); + }); + } }