diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 0d5895d2..f272d529 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -804,6 +804,14 @@ "acquisitions-units-storage.units.collection.get", "acquisitions-units-storage.memberships.collection.get" ] + }, + { + "methods": ["PUT"], + "pathPattern": "/finance/finance-data", + "permissionsRequired": ["finance.finance-data.collection.put"], + "modulePermissions": [ + "finance-storage.finance-data.collection.put" + ] } ] }, @@ -1378,6 +1386,20 @@ "displayName": "Finances - get finance data collection", "description": "Get finance data collection" }, + { + "permissionName": "finance.finance-data.collection.put", + "displayName": "Finances - update finance data collection", + "description": "Update finance data collection" + }, + { + "permissionName": "finance.finance-data.all", + "displayName": "Finance data - all permissions", + "description": "All finance data permissions", + "subPermissions": [ + "finance.finance-data.collection.get", + "finance.finance-data.collection.put" + ] + }, { "permissionName": "finance.all", "displayName": "Finance module - all permissions", @@ -1398,7 +1420,7 @@ "finance.expense-classes.all", "finance.acquisitions-units-assignments.all", "finance.fund-update-logs.all", - "finance.finance-data.collection.get" + "finance.finance-data.all" ], "visible": false }, diff --git a/ramls/acq-models b/ramls/acq-models index 0d8f736a..c6ebedd7 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit 0d8f736a3f2b5401a5cfad20a95bd831843f77a6 +Subproject commit c6ebedd7de52700d73516076bbdd45cdabb0185c diff --git a/ramls/finance-data.raml b/ramls/finance-data.raml index 6d807495..8438f3c7 100644 --- a/ramls/finance-data.raml +++ b/ramls/finance-data.raml @@ -26,7 +26,7 @@ resourceTypes: /finance/finance-data: type: collection-get: - exampleCollection: !include acq-models/mod-finance/examples/fy_finance_data_collection.sample + exampleCollection: !include acq-models/mod-finance/examples/fy_finance_data_collection_get.sample schemaCollection: fy-finance-data-collection get: description: Get finance data @@ -34,4 +34,37 @@ resourceTypes: searchable: { description: "with valid searchable fields: for example fiscalYearId", example: "[\"fiscalYearId\", \"7a4c4d30-3b63-4102-8e2d-3ee5792d7d02\", \"=\"]" }, pageable ] + put: + description: Update finance, budget as a bulk + is: [ validate ] + body: + application/json: + type: fy-finance-data-collection + example: !include acq-models/mod-finance/examples/fy_finance_data_collection_put.sample + responses: + 204: + description: "Items successfully updated" + 404: + description: "One or more items not found" + body: + text/plain: + example: | + "One or more items not found" + 400: + description: "Bad request, e.g. malformed request body or query parameter. Details of the error (e.g. name of the parameter or line/character number with malformed data) provided in the response." + body: + text/plain: + example: | + "unable to update items -- malformed JSON at 13:4" + 409: + description: "Optimistic locking version conflict" + body: + text/plain: + example: "version conflict" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "internal server error, contact administrator" + diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java index 765aafb7..06a2d5d0 100644 --- a/src/main/java/org/folio/config/ServicesConfiguration.java +++ b/src/main/java/org/folio/config/ServicesConfiguration.java @@ -219,7 +219,9 @@ FundUpdateLogService fundUpdateLogService(RestClient restClient) { } @Bean - FinanceDataService financeDataService(RestClient restClient, AcqUnitsService acqUnitsService) { - return new FinanceDataService(restClient, acqUnitsService); + FinanceDataService financeDataService(RestClient restClient, AcqUnitsService acqUnitsService, + TransactionApiService transactionApiService, FiscalYearService fiscalYearService, + FundUpdateLogService fundUpdateLogService) { + return new FinanceDataService(restClient, acqUnitsService, transactionApiService, fiscalYearService, fundUpdateLogService); } } diff --git a/src/main/java/org/folio/rest/impl/FinanceDataApi.java b/src/main/java/org/folio/rest/impl/FinanceDataApi.java index 70b5c4ac..405c6aec 100644 --- a/src/main/java/org/folio/rest/impl/FinanceDataApi.java +++ b/src/main/java/org/folio/rest/impl/FinanceDataApi.java @@ -10,6 +10,7 @@ import javax.ws.rs.core.Response; import org.folio.rest.annotations.Validate; import org.folio.rest.core.models.RequestContext; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; import org.folio.rest.jaxrs.resource.FinanceFinanceData; import org.folio.services.financedata.FinanceDataService; import org.folio.spring.SpringContextUtil; @@ -34,4 +35,12 @@ public void getFinanceFinanceData(String query, String totalRecords, int offset, .onSuccess(financeData -> asyncResultHandler.handle(succeededFuture(buildOkResponse(financeData)))) .onFailure(fail -> handleErrorResponse(asyncResultHandler, fail)); } + + @Override + @Validate + public void putFinanceFinanceData(FyFinanceDataCollection entity, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { + financeDataService.putFinanceData(entity, new RequestContext(vertxContext, okapiHeaders)) + .onSuccess(v -> asyncResultHandler.handle(succeededFuture(buildNoContentResponse()))) + .onFailure(fail -> handleErrorResponse(asyncResultHandler, fail)); + } } diff --git a/src/main/java/org/folio/services/financedata/FinanceDataService.java b/src/main/java/org/folio/services/financedata/FinanceDataService.java index 8d68415f..7c60d8c2 100644 --- a/src/main/java/org/folio/services/financedata/FinanceDataService.java +++ b/src/main/java/org/folio/services/financedata/FinanceDataService.java @@ -1,39 +1,229 @@ package org.folio.services.financedata; +import static io.vertx.core.Future.succeededFuture; +import static org.folio.rest.jaxrs.model.FundUpdateLog.Status.COMPLETED; +import static org.folio.rest.jaxrs.model.FundUpdateLog.Status.ERROR; 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 java.util.ArrayList; +import java.util.List; +import java.util.UUID; + import io.vertx.core.Future; +import lombok.extern.log4j.Log4j2; +import org.apache.commons.collections4.CollectionUtils; 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.exception.HttpException; +import org.folio.rest.jaxrs.model.Batch; +import org.folio.rest.jaxrs.model.Error; +import org.folio.rest.jaxrs.model.Errors; +import org.folio.rest.jaxrs.model.FundUpdateLog; +import org.folio.rest.jaxrs.model.FyFinanceData; import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.rest.jaxrs.model.JobDetails; +import org.folio.rest.jaxrs.model.Parameter; +import org.folio.rest.jaxrs.model.Transaction; +import org.folio.services.fiscalyear.FiscalYearService; +import org.folio.services.fund.FundUpdateLogService; import org.folio.services.protection.AcqUnitsService; +import org.folio.services.transactions.TransactionApiService; +@Log4j2 public class FinanceDataService { + private final RestClient restClient; private final AcqUnitsService acqUnitsService; + private final TransactionApiService transactionApiService; + private final FiscalYearService fiscalYearService; + private final FundUpdateLogService fundUpdateLogService; - public FinanceDataService(RestClient restClient, AcqUnitsService acqUnitsService) { + public FinanceDataService(RestClient restClient, AcqUnitsService acqUnitsService, TransactionApiService transactionApiService, + FiscalYearService fiscalYearService, FundUpdateLogService fundUpdateLogService) { this.restClient = restClient; this.acqUnitsService = acqUnitsService; + this.transactionApiService = transactionApiService; + this.fiscalYearService = fiscalYearService; + this.fundUpdateLogService = fundUpdateLogService; } + /** + * The method will fetch finance data with acq units restriction + * 1. First fetch acq units for the user + * 2. Build cql clause for finance data with acq units + * + * @param query query to filter finance data + * @param offset offset + * @param limit limit + * @param requestContext request context + * @return future with finance data collection + */ public Future getFinanceDataWithAcqUnitsRestriction(String query, int offset, int limit, RequestContext requestContext) { + log.debug("Trying to get finance data with acq units restriction, query={}", query); 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) { + 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); } + + /** + * The method will update finance data collection in one operation. + * 1. Validate finance data collection, if it fails validation exception will be thrown + * 2. Create allocation transactions, if it fails process will stop + * 3. Update finance data by invoking storage API + * 4. Save logs of the operation with COMPLETE or ERROR status + * + * @param financeDataCollection finance data collection to update + * @param requestContext request context + * @return future with void result + */ + public Future putFinanceData(FyFinanceDataCollection financeDataCollection, RequestContext requestContext) { + log.debug("Trying to update finance data collection with size: {}", financeDataCollection.getTotalRecords()); + if (CollectionUtils.isEmpty(financeDataCollection.getFyFinanceData())) { + log.info("putFinanceData:: Finance data collection is empty, nothing to update"); + return succeededFuture(); + } + + validateFinanceDataCollection(financeDataCollection, getFiscalYearId(financeDataCollection)); + + return processAllocationTransaction(financeDataCollection, requestContext) + .compose(v -> updateFinanceData(financeDataCollection, requestContext)) + .onSuccess(asyncResult -> processLogs(financeDataCollection, requestContext, COMPLETED)) + .onFailure(asyncResult -> processLogs(financeDataCollection, requestContext, ERROR)); + } + + private void validateFinanceDataCollection(FyFinanceDataCollection financeDataCollection, String fiscalYearId) { + for (int i = 0; i < financeDataCollection.getFyFinanceData().size(); i++) { + var financeData = financeDataCollection.getFyFinanceData().get(i); + validateFinanceDataFields(financeData, i, fiscalYearId); + + var allocationChange = financeData.getBudgetAllocationChange(); + var initialAllocation = financeData.getBudgetInitialAllocation(); + + if (allocationChange < 0 && Math.abs(allocationChange) > initialAllocation) { + var error = createError("Allocation change cannot be greater than initial allocation", + String.format("financeData[%s].budgetAllocationChange", i), String.valueOf(financeData.getBudgetAllocationChange())); + throw new HttpException(422, new Errors().withErrors(List.of(error))); + } + } + } + + private Future processAllocationTransaction(FyFinanceDataCollection fyFinanceDataCollection, + RequestContext requestContext) { + return fiscalYearService.getFiscalYearById(getFiscalYearId(fyFinanceDataCollection), requestContext) + .map(fiscalYear -> createAllocationTransactions(fyFinanceDataCollection, fiscalYear.getCurrency())) + .compose(transactions -> createBatchTransaction(transactions, requestContext)); + } + + private String getFiscalYearId(FyFinanceDataCollection fyFinanceDataCollection) { + return fyFinanceDataCollection.getFyFinanceData().get(0).getFiscalYearId(); + } + + private List createAllocationTransactions(FyFinanceDataCollection financeDataCollection, String currency) { + return financeDataCollection.getFyFinanceData().stream() + .map(financeData -> createAllocationTransaction(financeData, currency)) + .toList(); + } + + private Transaction createAllocationTransaction(FyFinanceData financeData, String currency) { + var allocationChange = financeData.getBudgetAllocationChange(); + log.info("createAllocationTransaction:: Creating allocation transaction for fund '{}' and budget '{}' with allocation '{}'", + financeData.getFundId(), financeData.getBudgetId(), allocationChange); + + var transaction = new Transaction() + .withTransactionType(Transaction.TransactionType.ALLOCATION) + .withId(UUID.randomUUID().toString()) + .withAmount(Math.abs(allocationChange)) + .withFiscalYearId(financeData.getFiscalYearId()) + .withSource(Transaction.Source.USER) + .withCurrency(currency); + + // For negative allocation (decrease), use fromFundId + // For positive allocation (increase), use toFundId + if (allocationChange > 0) { + transaction.withToFundId(financeData.getFundId()); + } else { + transaction.withFromFundId(financeData.getFundId()); + } + + return transaction; + } + + public Future createBatchTransaction(List transactions, RequestContext requestContext) { + Batch batch = new Batch().withTransactionsToCreate(transactions); + return transactionApiService.processBatch(batch, requestContext); + } + + private Future updateFinanceData(FyFinanceDataCollection financeDataCollection, + RequestContext requestContext) { + log.debug("updateFinanceData:: Trying to update finance data collection with size: {}", financeDataCollection.getTotalRecords()); + return restClient.put(resourcesPath(FINANCE_DATA_STORAGE), financeDataCollection, requestContext); + } + + private void processLogs(FyFinanceDataCollection financeDataCollection, + RequestContext requestContext, FundUpdateLog.Status status) { + var jobDetails = new JobDetails().withAdditionalProperty("fyFinanceData", financeDataCollection.getFyFinanceData()); + var fundUpdateLog = new FundUpdateLog().withId(UUID.randomUUID().toString()) + .withJobName("Update finance data") // TODO: Update job name generation + .withStatus(status) + .withRecordsCount(financeDataCollection.getTotalRecords()) + .withJobDetails(jobDetails) + .withJobNumber(1); + fundUpdateLogService.createFundUpdateLog(fundUpdateLog, requestContext); + } + + private void validateFinanceDataFields(FyFinanceData financeData, int i, String fiscalYearId) { + var errors = new Errors().withErrors(new ArrayList<>()); + + if (!financeData.getFiscalYearId().equals(fiscalYearId)) { + errors.getErrors().add(createError( + String.format("Fiscal year ID must be the same as other fiscal year ID(s) '[%s]' in the request", fiscalYearId), + String.format("financeData[%s].fiscalYearId", i), financeData.getFiscalYearId()) + ); + } + + validateField(errors, String.format("financeData[%s].fundCode", i), financeData.getFundCode(), "Fund code is required"); + validateField(errors, String.format("financeData[%s].fundName", i), financeData.getFundName(), "Fund name is required"); + validateField(errors, String.format("financeData[%s].fundDescription", i), financeData.getFundDescription(), "Fund description is required"); + validateField(errors, String.format("financeData[%s].fundStatus", i), financeData.getFundStatus(), "Fund status is required"); + validateField(errors, String.format("financeData[%s].budgetId", i), financeData.getBudgetId(), "Budget ID is required"); + validateField(errors, String.format("financeData[%s].budgetName", i), financeData.getBudgetName(), "Budget name is required"); + validateField(errors, String.format("financeData[%s].budgetStatus", i), financeData.getBudgetStatus(), "Budget status is required"); + validateField(errors, String.format("financeData[%s].budgetInitialAllocation", i), financeData.getBudgetInitialAllocation(), "Budget initial allocation is required"); + validateField(errors, String.format("financeData[%s].budgetAllocationChange", i), financeData.getBudgetAllocationChange(), "Allocation change is required"); + validateField(errors, String.format("financeData[%s].budgetAllowableExpenditure", i), financeData.getBudgetAllowableExpenditure(), "Budget allowable expenditure is required"); + validateField(errors, String.format("financeData[%s].budgetAllowableEncumbrance", i), financeData.getBudgetAllowableEncumbrance(), "Budget allowable encumbrance is required"); + validateField(errors, String.format("financeData[%s].transactionDescription", i), financeData.getTransactionDescription(), "Transaction description is required"); + validateField(errors, String.format("financeData[%s].transactionTag", i), financeData.getTransactionTag(), "Transaction tag is required"); + + if (CollectionUtils.isNotEmpty(errors.getErrors())) { + throw new HttpException(422, errors); + } + } + + private void validateField(Errors errors, String fieldName, Object fieldValue, String errorMessage) { + if (fieldValue == null) { + errors.getErrors().add(createError(errorMessage, fieldName, "null")); + } + } + + private Error createError(String message, String key, String value) { + log.warn("Validation error: {}", message); + var param = new Parameter().withKey(key).withValue(value); + return new Error().withMessage(message).withParameters(List.of(param)); + } } diff --git a/src/main/java/org/folio/services/transactions/TransactionApiService.java b/src/main/java/org/folio/services/transactions/TransactionApiService.java index b331111a..9345c108 100644 --- a/src/main/java/org/folio/services/transactions/TransactionApiService.java +++ b/src/main/java/org/folio/services/transactions/TransactionApiService.java @@ -117,7 +117,6 @@ public Future unreleaseEncumbrance(String id, RequestContext requestContex .onFailure(t -> log.error("Error unreleasing encumbrance, id={}", id, t)); } - private void validateTransactionType(Transaction transaction, TransactionType transactionType) { if (transaction.getTransactionType() != transactionType) { log.warn("validateTransactionType:: Transaction '{}' type mismatch. '{}' expected", transaction.getId(), transactionType); diff --git a/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java index b2e986d2..533f90a6 100644 --- a/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java +++ b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java @@ -2,16 +2,20 @@ import static io.vertx.core.Future.failedFuture; import static io.vertx.core.Future.succeededFuture; +import static java.util.Collections.emptyList; 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.NO_CONTENT; 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.RestTestUtils.verifyPut; 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.rest.util.TestUtils.getMockData; 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; @@ -27,6 +31,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +39,7 @@ import java.util.concurrent.TimeoutException; import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; import io.vertx.ext.web.handler.HttpException; import org.folio.ApiTestSuite; import org.folio.rest.core.models.RequestContext; @@ -42,6 +48,7 @@ import org.folio.rest.jaxrs.model.FyFinanceDataCollection; import org.folio.services.financedata.FinanceDataService; import org.folio.services.protection.AcqUnitsService; +import org.folio.util.CopilotGenerated; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -50,6 +57,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; +@CopilotGenerated(partiallyGenerated = true) public class FinanceDataApiTest { @Autowired @@ -128,6 +136,68 @@ void negative_testGetFinanceFinanceDataFailure() { assertThat(errors.getErrors().get(0).getCode(), is(GENERIC_ERROR_CODE.getCode())); } + @Test + void positive_testPutFinanceFinanceDataSuccess() throws IOException { + var jsonData = getMockData("mockdata/finance-data/fy_finance_data_collection_put.json"); + var jsonObject = new JsonObject(jsonData); + var financeDataCollection = jsonObject.mapTo(FyFinanceDataCollection.class); + + when(financeDataService.putFinanceData(any(FyFinanceDataCollection.class), any(RequestContext.class))) + .thenReturn(succeededFuture(null)); + + verifyPut(FINANCE_DATA_ENDPOINT, financeDataCollection, "", NO_CONTENT.getStatusCode()); + + verify(financeDataService).putFinanceData(eq(financeDataCollection), any(RequestContext.class)); + } + + @Test + void negative_testPutFinanceFinanceDataFailure() throws IOException { + var jsonData = getMockData("mockdata/finance-data/fy_finance_data_collection_put.json"); + var jsonObject = new JsonObject(jsonData); + var financeDataCollection = jsonObject.mapTo(FyFinanceDataCollection.class); + + Future failedFuture = failedFuture(new HttpException(500, INTERNAL_SERVER_ERROR.getReasonPhrase())); + + when(financeDataService.putFinanceData(any(FyFinanceDataCollection.class), any(RequestContext.class))) + .thenReturn(failedFuture); + + var errors = verifyPut(FINANCE_DATA_ENDPOINT, financeDataCollection, 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())); + verify(financeDataService).putFinanceData(eq(financeDataCollection), any(RequestContext.class)); + } + + @Test + void negative_testPutFinanceFinanceDataBadRequest() throws IOException { + var jsonData = getMockData("mockdata/finance-data/fy_finance_data_collection_put.json"); + var jsonObject = new JsonObject(jsonData); + var financeDataCollection = jsonObject.mapTo(FyFinanceDataCollection.class); + // Modify one field to make it invalid + financeDataCollection.getFyFinanceData().get(0).setFiscalYearId(null); + + var errors = verifyPut(FINANCE_DATA_ENDPOINT, financeDataCollection, APPLICATION_JSON, 422) + .as(Errors.class); + + assertThat(errors.getErrors(), hasSize(1)); + assertThat(errors.getErrors().get(0).getCode(), is("jakarta.validation.constraints.NotNull.message")); + } + + @Test + void testPutFinanceFinanceDataWithEmptyCollection() { + FyFinanceDataCollection entity = new FyFinanceDataCollection() + .withFyFinanceData(emptyList()) + .withTotalRecords(0); + + when(financeDataService.putFinanceData(any(FyFinanceDataCollection.class), any(RequestContext.class))) + .thenReturn(succeededFuture(null)); + + verifyPut(FINANCE_DATA_ENDPOINT, entity, "", NO_CONTENT.getStatusCode()); + + verify(financeDataService).putFinanceData(eq(entity), any(RequestContext.class)); + } + static class ContextConfiguration { @Bean public FinanceDataService financeDataService() { diff --git a/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java b/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java index 136dad1b..de150a9e 100644 --- a/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java +++ b/src/test/java/org/folio/services/financedata/FinanceDataServiceTest.java @@ -1,23 +1,43 @@ package org.folio.services.financedata; +import static io.vertx.core.Future.failedFuture; import static io.vertx.core.Future.succeededFuture; +import static java.util.Collections.singletonList; 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.assertThrows; 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.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.UUID; +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; import org.folio.rest.core.RestClient; import org.folio.rest.core.models.RequestContext; +import org.folio.rest.exception.HttpException; +import org.folio.rest.jaxrs.model.FiscalYear; +import org.folio.rest.jaxrs.model.FundTags; +import org.folio.rest.jaxrs.model.FundUpdateLog; +import org.folio.rest.jaxrs.model.FyFinanceData; import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.rest.jaxrs.model.Transaction; +import org.folio.services.fiscalyear.FiscalYearService; +import org.folio.services.fund.FundUpdateLogService; import org.folio.services.protection.AcqUnitsService; +import org.folio.services.transactions.TransactionApiService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,22 +46,21 @@ 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; + @Mock + private FundUpdateLogService fundUpdateLogService; + @Mock + private TransactionApiService transactionApiService; + @Mock + private FiscalYearService fiscalYearService; private RequestContext requestContextMock; private AutoCloseable closeable; @@ -106,4 +125,124 @@ void negative_shouldReturnEmptyCollectionWhenFinanceDataNotFound(VertxTestContex }); } + @Test + void positive_testPutFinanceData_PutFinanceDataSuccessfully(VertxTestContext vertxTestContext) { + var financeDataCollection = new FyFinanceDataCollection().withFyFinanceData(List.of(createValidFyFinanceData())); + var fiscalYear = new FiscalYear().withCurrency("USD"); + + when(restClient.put(anyString(), any(), any())).thenReturn(succeededFuture()); + when(transactionApiService.processBatch(any(), any())).thenReturn(succeededFuture()); + when(fundUpdateLogService.createFundUpdateLog(any(), any())).thenReturn(succeededFuture()); + when(fiscalYearService.getFiscalYearById(any(), any())).thenReturn(succeededFuture(fiscalYear)); + + var future = financeDataService.putFinanceData(financeDataCollection, requestContextMock); + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + verify(fundUpdateLogService).createFundUpdateLog(argThat(log -> + log.getStatus() == FundUpdateLog.Status.COMPLETED + ), eq(requestContextMock)); + vertxTestContext.completeNow(); + }); + } + + @Test + void negative_testPutFinanceData_LogErrorWhenPutFinanceDataFails(VertxTestContext vertxTestContext) { + var financeDataCollection = new FyFinanceDataCollection().withFyFinanceData(List.of(createValidFyFinanceData())); + var fiscalYear = new FiscalYear().withCurrency("USD"); + + when(fiscalYearService.getFiscalYearById(any(), any())).thenReturn(succeededFuture(fiscalYear)); + when(restClient.put(anyString(), any(), any())).thenReturn(failedFuture("Error")); + when(transactionApiService.processBatch(any(), any())).thenReturn(succeededFuture()); + when(fundUpdateLogService.createFundUpdateLog(any(), any())).thenReturn(succeededFuture()); + + var future = financeDataService.putFinanceData(financeDataCollection, requestContextMock); + vertxTestContext.assertFailure(future) + .onComplete(result -> { + assertTrue(result.failed()); + verify(fundUpdateLogService).createFundUpdateLog(any(), eq(requestContextMock)); + vertxTestContext.completeNow(); + }); + } + + @Test + void negative_testPutFinanceData_FailureInProcessAllocationTransaction(VertxTestContext vertxTestContext) { + FyFinanceDataCollection financeData = new FyFinanceDataCollection(); + FyFinanceData data = createValidFyFinanceData(); + financeData.setFyFinanceData(singletonList(data)); + FiscalYear fiscalYear = new FiscalYear().withCurrency("USD"); + + when(fiscalYearService.getFiscalYearById(any(), any())).thenReturn(succeededFuture(fiscalYear)); + when(transactionApiService.processBatch(any(), any())).thenReturn(failedFuture("Process failed")); + + financeDataService.putFinanceData(financeData, requestContextMock) + .onComplete(vertxTestContext.failing(error -> { + verify(fundUpdateLogService).createFundUpdateLog(argThat(log -> + log.getStatus() == FundUpdateLog.Status.ERROR + ), eq(requestContextMock)); + vertxTestContext.completeNow(); + })); + } + + @Test + void testCreateAllocationTransactionUsingReflection() throws Exception { + var data = createValidFyFinanceData(); + var fiscalYear = new FiscalYear().withCurrency("USD"); + + // Use reflection to access the private method + var method = FinanceDataService.class.getDeclaredMethod("createAllocationTransaction", FyFinanceData.class, String.class); + method.setAccessible(true); + + Transaction transaction = (Transaction) method.invoke(financeDataService, data, fiscalYear.getCurrency()); + assertEquals(Transaction.TransactionType.ALLOCATION, transaction.getTransactionType()); + assertEquals(data.getFundId(), transaction.getToFundId()); + assertEquals(fiscalYear.getCurrency(), transaction.getCurrency()); + assertEquals(50.0, transaction.getAmount()); // Assuming initial allocation is 100 and change is 50 + } + + @Test + void testPutFinanceData_InvalidAllocationChange() { + var financeData = createValidFyFinanceData(); + financeData.setBudgetInitialAllocation(100.0); + financeData.setBudgetAllocationChange(-150.0); + var collection = new FyFinanceDataCollection() + .withFyFinanceData(Collections.singletonList(financeData)) + .withTotalRecords(1); + + var exception = assertThrows(HttpException.class, + () -> financeDataService.putFinanceData(collection, new RequestContext(Vertx.vertx().getOrCreateContext(), new HashMap<>()))); + assertEquals("Allocation change cannot be greater than initial allocation", exception.getErrors().getErrors().get(0).getMessage()); + } + + @Test + void testPutFinanceData_MissingRequiredField() { + var financeData = createValidFyFinanceData(); + financeData.setBudgetInitialAllocation(null); + var collection = new FyFinanceDataCollection() + .withFyFinanceData(Collections.singletonList(financeData)) + .withTotalRecords(1); + + var exception = assertThrows(HttpException.class, + () -> financeDataService.putFinanceData(collection, new RequestContext(Vertx.vertx().getOrCreateContext(), new HashMap<>()))); + assertEquals("Budget initial allocation is required", exception.getErrors().getErrors().get(0).getMessage()); + } + + private FyFinanceData createValidFyFinanceData() { + return new FyFinanceData() + .withFundId(UUID.randomUUID().toString()) + .withFundCode("FUND-001") + .withFundName("Test Fund") + .withFundDescription("Test Fund Description") + .withFundStatus(FyFinanceData.FundStatus.ACTIVE) + .withBudgetId(UUID.randomUUID().toString()) + .withBudgetName("Test Budget") + .withBudgetStatus(FyFinanceData.BudgetStatus.ACTIVE) + .withBudgetInitialAllocation(100.0) + .withBudgetAllocationChange(50.0) + .withBudgetAllowableExpenditure(150.0) + .withBudgetAllowableEncumbrance(150.0) + .withTransactionDescription("Test Transaction") + .withTransactionTag(new FundTags().withTagList(List.of("tag1", "tag2"))) + .withFiscalYearId(UUID.randomUUID().toString()); + } } diff --git a/src/test/java/org/folio/util/CopilotGenerated.java b/src/test/java/org/folio/util/CopilotGenerated.java new file mode 100644 index 00000000..6a4c6aca --- /dev/null +++ b/src/test/java/org/folio/util/CopilotGenerated.java @@ -0,0 +1,25 @@ +package org.folio.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to indicate that test(s) in the class are generated by GitHub Copilot. + *

+ * Set value or partiallyGenerated attribute to true + * if the generated test(s) were significantly modified/altered by the developer. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.SOURCE) +public @interface CopilotGenerated { + + @AliasFor("partiallyGenerated") + boolean value() default false; + + @AliasFor("value") + boolean partiallyGenerated() default false; +} diff --git a/src/test/resources/mockdata/finance-data/fy_finance_data_collection_put.json b/src/test/resources/mockdata/finance-data/fy_finance_data_collection_put.json new file mode 100644 index 00000000..da6a564c --- /dev/null +++ b/src/test/resources/mockdata/finance-data/fy_finance_data_collection_put.json @@ -0,0 +1,67 @@ +{ + "fyFinanceData": [ + { + "fiscalYearId": "123e4567-e89b-12d3-a456-426614174004", + "fiscalYearCode": "FY2023", + "fundId": "123e4567-e89b-12d3-a456-426614174000", + "fundCode": "FND001", + "fundName": "General Fund", + "fundDescription": "This fund is used for general purposes.", + "fundStatus": "Active", + "fundTags": { + "tagList": ["Education", "Research"] + }, + "fundAcqUnitIds": ["123e4567-e89b-12d3-a456-426614174006"], + "ledgerId": "123e4567-e89b-12d3-a456-426614174015", + "ledgerCode": "LED001", + "budgetId": "123e4567-e89b-12d3-a456-426614174001", + "budgetName": "Annual Budget", + "budgetStatus": "Active", + "budgetInitialAllocation": 1000000, + "budgetCurrentAllocation": 950000, + "budgetAllocationChange": -50000, + "budgetAllowableExpenditure": 80, + "budgetAllowableEncumbrance": 90, + "budgetAcqUnitIds": ["123e4567-e89b-12d3-a456-426614174008"], + "groupId": "123e4567-e89b-12d3-a456-426614174025", + "groupCode": "GRP001", + "transactionDescription": "End of year adjustment", + "transactionTag": { + "tagList": ["Urgent", "Review"] + }, + "updateType": "Commit" + }, + { + "fiscalYearId": "123e4567-e89b-12d3-a456-426614174005", + "fiscalYearCode": "FY2023", + "fundId": "123e4567-e89b-12d3-a456-426614174002", + "fundCode": "FND002", + "fundName": "Research Fund", + "fundDescription": "This fund is used for research purposes.", + "fundStatus": "Frozen", + "fundTags": { + "tagList": ["Science", "Innovation"] + }, + "fundAcqUnitIds": ["123e4567-e89b-12d3-a456-426614174007"], + "ledgerId": "123e4567-e89b-12d3-a456-426614174016", + "ledgerCode": "LED002", + "budgetId": "123e4567-e89b-12d3-a456-426614174003", + "budgetName": "Research Budget", + "budgetStatus": "Planned", + "budgetInitialAllocation": 500000, + "budgetCurrentAllocation": 450000, + "budgetAllocationChange": -50000, + "budgetAllowableExpenditure": 70, + "budgetAllowableEncumbrance": 85, + "budgetAcqUnitIds": ["123e4567-e89b-12d3-a456-426614174009"], + "groupId": "123e4567-e89b-12d3-a456-426614174026", + "groupCode": "GRP002", + "transactionDescription": "Mid-year adjustment", + "transactionTag": { + "tagList": ["Urgent", "Review"] + }, + "updateType": "Preview" + } + ], + "totalRecords": 2 +}