blob: d643d23ff0018aaefddba0a0de8ba1a564a2eb8b [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.integrationtests;
import static org.apache.fineract.integrationtests.common.CommonConstants.RESPONSE_RESOURCE_ID;
import static org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper.PAYMENT_TYPE_ID;
import static org.apache.fineract.integrationtests.common.system.DatatableHelper.addDatatableColumn;
import static org.apache.http.HttpStatus.SC_CONFLICT;
import static org.apache.http.HttpStatus.SC_FORBIDDEN;
import static org.apache.http.HttpStatus.SC_OK;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.base.Strings;
import com.google.gson.Gson;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.path.json.JsonPath;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Collectors;
import org.apache.fineract.batch.domain.BatchRequest;
import org.apache.fineract.batch.domain.BatchResponse;
import org.apache.fineract.batch.domain.Header;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.integrationtests.common.BatchHelper;
import org.apache.fineract.integrationtests.common.BusinessDateHelper;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsStatusChecker;
import org.apache.fineract.integrationtests.common.savings.SavingsTransactionData;
import org.apache.fineract.integrationtests.common.system.DatatableHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@SuppressWarnings({ "rawtypes" })
public class SavingsAccountTransactionTest {
private static final Logger log = LoggerFactory.getLogger(SavingsAccountTransactionTest.class);
public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL";
final String startDateString = "03 June 2023";
final String depositDateString = "05 June 2023";
final String withdrawDateString = "10 June 2023";
private ResponseSpecification responseSpec;
private ResponseSpecification concurrentResponseSpec;
private ResponseSpecification deadlockResponseSpec;
private RequestSpecification requestSpec;
private SavingsProductHelper savingsProductHelper;
private SavingsAccountHelper savingsAccountHelper;
private DatatableHelper datatableHelper;
@BeforeEach
public void setup() {
Utils.initializeRESTAssured();
this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
this.responseSpec = new ResponseSpecBuilder().expectStatusCode(SC_OK).build();
this.concurrentResponseSpec = new ResponseSpecBuilder().expectStatusCode(anyOf(is(SC_OK), is(SC_CONFLICT))).build();
this.deadlockResponseSpec = new ResponseSpecBuilder().expectStatusCode(anyOf(is(SC_OK), is(SC_CONFLICT), is(SC_FORBIDDEN))).build();
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.savingsProductHelper = new SavingsProductHelper();
this.datatableHelper = new DatatableHelper(this.requestSpec, this.responseSpec);
}
@Test
public void verifySavingsTransactionSubmittedOnDateAndTransactionDate() throws JsonProcessingException {
LocalDate today = Utils.getLocalDateOfTenant();
try {
enableBusinessDate(requestSpec, responseSpec, true);
BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, today);
LocalDate depositDate = Utils.getDateAsLocalDate(depositDateString);
LocalDate withdrawDate = Utils.getDateAsLocalDate(withdrawDateString);
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDateString);
assertNotNull(clientID);
final Integer savingsId = createApproveActivateSavingsAccountDailyPosting(clientID, startDateString);
assertNotNull(savingsId);
performSavingsTransaction(savingsId, "100", depositDate, true);
performSavingsTransaction(savingsId, "50", withdrawDate, false);
} finally {
enableBusinessDate(requestSpec, responseSpec, false);
}
}
@Test
public void testConcurrentSavingsTransactions() {
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec);
ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID);
final Integer savingsProductId = createSavingsProductDailyPosting();
assertNotNull(savingsProductId);
final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
this.savingsAccountHelper.approveSavings(savingsId);
HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId);
SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap);
SavingsAccountHelper concurrentHelper = new SavingsAccountHelper(requestSpec, concurrentResponseSpec);
String transactionDate = SavingsAccountHelper.TRANSACTION_DATE;
String transactionAmount = "10";
ExecutorService executor = Executors.newFixedThreadPool(30);
for (int i = 0; i < 10; i++) {
log.info("Starting concurrent transaction number {}", i);
SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build();
Runnable worker = new TransactionExecutor(concurrentHelper, savingsId, transactionData);
executor.execute(worker);
}
executor.shutdown();
// Wait until all threads are finish
while (!executor.isTerminated()) {
}
log.info("\nFinished all threads");
}
@Test
public void testConcurrentSavingsBatchTransactions() {
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec);
ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID);
final Integer savingsProductId = createSavingsProductDailyPosting();
assertNotNull(savingsProductId);
final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
this.savingsAccountHelper.approveSavings(savingsId);
HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId);
SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap);
// creating datatable for client entity
final HashMap<String, Object> columnMap = new HashMap<>();
String datatableName = Utils.uniqueRandomStringGenerator("dt_savings_transaction_", 5).toLowerCase();
columnMap.put("datatableName", datatableName);
columnMap.put("apptableName", "m_savings_account_transaction");
columnMap.put("multiRow", false);
String string1 = "string1";
String string2 = "string2";
List<String> columnNames = List.of(string1, string2);
final List<HashMap<String, Object>> datatableColumnsList = new ArrayList<>();
addDatatableColumn(datatableColumnsList, string1, "String", false, 10, null);
addDatatableColumn(datatableColumnsList, string2, "String", true, 12, null);
columnMap.put("columns", datatableColumnsList);
String datatableJson = new Gson().toJson(columnMap);
this.datatableHelper.createDatatable(datatableJson, "");
SavingsAccountHelper batchWithTransactionHelper = new SavingsAccountHelper(requestSpec, concurrentResponseSpec);
SavingsAccountHelper batchWithoutTransactionHelper = new SavingsAccountHelper(requestSpec,
new ResponseSpecBuilder().expectStatusCode(anyOf(is(SC_OK), is(SC_CONFLICT), is(SC_FORBIDDEN))).build());
String transactionDate = SavingsAccountHelper.TRANSACTION_DATE;
String transactionAmount = "10";
ExecutorService executor = Executors.newFixedThreadPool(30);
ArrayList<Future<?>> results = new ArrayList<>();
for (int i = 0; i < 5; i++) {
log.info("Starting concurrent transaction number {}", i);
SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build();
Runnable workerWithTransaction = new TransactionExecutor(batchWithTransactionHelper, savingsId, transactionData, true,
datatableName, columnNames);
results.add(executor.submit(workerWithTransaction));
Runnable workerWithoutTransaction = new TransactionExecutor(batchWithoutTransactionHelper, savingsId, transactionData, false,
datatableName, columnNames);
results.add(executor.submit(workerWithoutTransaction));
}
executor.shutdown();
// Wait until all threads are finish
while (!executor.isTerminated()) {
}
this.datatableHelper.deleteDatatable(datatableName);
try {
for (Future<?> result : results) {
assertNull(result.get());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("\nFinished all threads");
}
@Test
public void testDeadlockSavingsBatchTransactions() {
final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec);
ClientHelper.verifyClientCreatedOnServer(requestSpec, responseSpec, clientID);
final Integer savingsProductId = createSavingsProductDailyPosting();
assertNotNull(savingsProductId);
final Integer savingsId1 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
savingsAccountHelper.approveSavings(savingsId1);
savingsAccountHelper.activateSavings(savingsId1);
final Integer savingsId2 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
savingsAccountHelper.approveSavings(savingsId2);
savingsAccountHelper.activateSavings(savingsId2);
SavingsAccountHelper batchWithTransactionHelper = new SavingsAccountHelper(requestSpec, deadlockResponseSpec);
String transactionDate = SavingsAccountHelper.TRANSACTION_DATE;
String transactionAmount = "10";
ExecutorService executor = Executors.newFixedThreadPool(30);
ArrayList<Future<?>> results = new ArrayList<>();
for (int i = 0; i < 5; i++) {
log.info("Starting concurrent transaction number {}", i);
SavingsTransactionData transactionData1 = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note1_" + i).build();
results.add(executor.submit(() -> {
runDeadlockBatch(batchWithTransactionHelper, savingsId1, savingsId2, transactionData1);
}));
SavingsTransactionData transactionData2 = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note2_" + i).build();
results.add(executor.submit(() -> {
runDeadlockBatch(batchWithTransactionHelper, savingsId2, savingsId1, transactionData2);
}));
}
executor.shutdown();
// Wait until all threads are finish
while (!executor.isTerminated()) {
}
try {
for (Future<?> result : results) {
assertNull(result.get());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("\nFinished all threads");
}
private void enableBusinessDate(RequestSpecification requestSpec, ResponseSpecification responseSpec, boolean enable) {
GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, enable);
}
private void performSavingsTransaction(Integer savingsId, String amount, LocalDate transactionDate, boolean isDeposit) {
String transactionType = isDeposit ? "Deposit" : "Withdrawal";
Integer transactionId = isDeposit
? (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, amount, depositDateString, RESPONSE_RESOURCE_ID)
: (Integer) this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, amount, withdrawDateString,
RESPONSE_RESOURCE_ID);
assertNotNull(transactionId);
HashMap transaction = savingsAccountHelper.getSavingsTransaction(savingsId, transactionId);
assertNotNull(transaction);
assertEquals(transactionId, (Integer) transaction.get("id"), "Check Savings " + transactionType + " Transaction");
LocalDate transactionDateFromResponse = extractLocalDate(transaction, "date");
assertTrue(DateUtils.isEqual(transactionDate, transactionDateFromResponse), "Transaction Date check for Savings " + transactionType
+ " Transaction. Expected: " + transactionDate + ", current: " + transactionDateFromResponse);
LocalDate submittedOnDate = Utils.getLocalDateOfTenant();
LocalDate submittedOnDateFromResponse = extractLocalDate(transaction, "submittedOnDate");
assertTrue(DateUtils.isEqual(submittedOnDate, submittedOnDateFromResponse), "Submitted On Date check for Savings " + transactionType
+ " Transaction. Expected: " + submittedOnDate + ", current: " + submittedOnDateFromResponse);
}
private LocalDate extractLocalDate(HashMap transactionMap, String fieldName) {
List<Integer> dateStringList = (List<Integer>) transactionMap.get(fieldName);
LocalDate extractedDate = dateStringList.stream()
.collect(Collectors.collectingAndThen(Collectors.toList(), list -> LocalDate.of(list.get(0), list.get(1), list.get(2))));
return extractedDate;
}
private Integer createApproveActivateSavingsAccountDailyPosting(final Integer clientID, final String startDate) {
final Integer savingsProductID = createSavingsProductDailyPosting();
assertNotNull(savingsProductID);
return savingsAccountHelper.createApproveActivateSavingsAccount(clientID, savingsProductID, startDate);
}
private Integer createSavingsProductDailyPosting() {
final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily()
.withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build();
return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec);
}
public static class TransactionExecutor implements Runnable {
private final SavingsAccountHelper savingsHelper;
private final Integer savingsId;
SavingsTransactionData transactionData;
private final boolean batch;
private final boolean enclosingTransaction;
private final String datatableName;
private final List<String> columnNames;
private TransactionExecutor(SavingsAccountHelper savingsHelper, Integer savingsId, SavingsTransactionData transactionData,
boolean batch, boolean enclosingTransaction, String datatableName, List<String> columnNames) {
this.savingsId = savingsId;
this.savingsHelper = savingsHelper;
this.transactionData = transactionData;
this.batch = batch;
this.enclosingTransaction = enclosingTransaction;
this.datatableName = datatableName;
this.columnNames = columnNames;
}
TransactionExecutor(SavingsAccountHelper savingsAccountHelper, Integer savingsId, SavingsTransactionData transactionData) {
this(savingsAccountHelper, savingsId, transactionData, false, false, null, null);
}
TransactionExecutor(SavingsAccountHelper batchHelper, Integer savingsId, SavingsTransactionData transactionData,
boolean enclosingTransaction, String datatableName, List<String> columnNames) {
this(batchHelper, savingsId, transactionData, true, enclosingTransaction, datatableName, columnNames);
}
@Override
public void run() {
log.info("Details of passed concurrent transaction, details (date, amount, note, savingsId) are {},{},{},{}",
transactionData.getTransactionDate(), transactionData.getTransactionAmount(), transactionData.getNote(), savingsId);
if (batch) {
final BatchRequest depositRequest = BatchHelper.depositSavingAccount(1L, savingsId.longValue(), transactionData);
Set<Header> headers = Optional.ofNullable(depositRequest.getHeaders()).orElse(new HashSet<>(1));
headers.add(new Header("Idempotency-Key", UUID.randomUUID().toString()));
depositRequest.setHeaders(headers);
BatchRequest addEntryRequest = BatchHelper.createDatatableEntryRequest("$.resourceId", datatableName, columnNames);
addEntryRequest.setReference(1L);
BatchRequest deleteEntryRequest = BatchHelper.deleteDatatableEntryRequest("$.transactionId", datatableName, null);
final BatchRequest withdrawRequest = BatchHelper.withdrawSavingAccount(2L, savingsId.longValue(), transactionData);
headers = Optional.ofNullable(withdrawRequest.getHeaders()).orElse(new HashSet<>(1));
headers.add(new Header("Idempotency-Key", UUID.randomUUID().toString()));
withdrawRequest.setHeaders(headers);
String json = BatchHelper.toJsonString(Arrays.asList(depositRequest, addEntryRequest, deleteEntryRequest, withdrawRequest));
RequestSpecification requestSpec = savingsHelper.getRequestSpec();
ResponseSpecification responseSpec = savingsHelper.getResponseSpec();
final List<BatchResponse> responses = enclosingTransaction
? BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, json)
: BatchHelper.postBatchRequestsWithoutEnclosingTransaction(requestSpec, responseSpec, json);
assertNotNull(responses, "Responses");
if (enclosingTransaction) {
Integer statusCode1 = responses.get(0).getStatusCode();
assertNotNull(statusCode1, "First enlosingTransaction response status code");
assertTrue(SC_OK == statusCode1 || SC_CONFLICT == statusCode1, "Status code: " + statusCode1);
if (SC_OK == statusCode1) {
assertEquals(4, responses.size(), "Response size for enlosingTransaction OK response");
Integer statusCode4 = responses.get(3).getStatusCode();
assertNotNull(statusCode4, "Last enlosingTransaction OK response status code");
assertEquals(SC_OK, statusCode4, "Last enlosingTransaction OK response status code");
} else {
assertEquals(1, responses.size(), "Response size for enlosingTransaction failed response");
}
} else {
assertEquals(4, responses.size(), "Response size for without-enlosingTransaction response");
Integer statusCode1 = responses.get(0).getStatusCode();
assertNotNull(statusCode1, "First without-enlosingTransaction response status code");
assertTrue(SC_OK == statusCode1 || SC_CONFLICT == statusCode1,
"First without-enlosingTransaction response status code: " + statusCode1);
Integer statusCode4 = responses.get(3).getStatusCode();
assertNotNull(statusCode4, "Last without-enlosingTransaction response status code");
assertTrue(
SC_OK == statusCode1 ? (SC_OK == statusCode4 || SC_CONFLICT == statusCode4)
: (SC_FORBIDDEN == statusCode4 || SC_CONFLICT == statusCode4),
"Last without-enlosingTransaction response status code: " + statusCode4);
}
} else {
String json = transactionData.getJson();
String response = (String) this.savingsHelper.depositToSavingsAccount(savingsId, json, null);
boolean success = checkConcurrentResponse(response);
if (success) {
response = (String) this.savingsHelper.withdrawalFromSavingsAccount(savingsId, json, null);
checkConcurrentResponse(response);
}
}
}
private static boolean checkConcurrentResponse(String response) {
assertNotNull(response, "Single response");
JsonPath res = JsonPath.from(response);
String statusCode = res.get("httpStatusCode");
if (statusCode == null) {
assertNotNull(res.get(RESPONSE_RESOURCE_ID), "Single response " + RESPONSE_RESOURCE_ID);
return true;
}
assertEquals(String.valueOf(SC_CONFLICT), statusCode, "Single response status code");
return false;
}
}
private void runDeadlockBatch(SavingsAccountHelper savingsHelper, Integer savingsId1, Integer savingsId2,
SavingsTransactionData transactionData) {
final BatchRequest depositRequest1 = BatchHelper.depositSavingAccount(1L, savingsId1.longValue(), transactionData);
final BatchRequest withdrawRequest1 = BatchHelper.withdrawSavingAccount(2L, savingsId1.longValue(), transactionData);
final BatchRequest depositRequest2 = BatchHelper.depositSavingAccount(3L, savingsId2.longValue(), transactionData);
final BatchRequest withdrawRequest2 = BatchHelper.withdrawSavingAccount(4L, savingsId2.longValue(), transactionData);
String json = BatchHelper.toJsonString(Arrays.asList(depositRequest1, withdrawRequest1, depositRequest2, withdrawRequest2));
RequestSpecification requestSpec = savingsHelper.getRequestSpec();
ResponseSpecification responseSpec = savingsHelper.getResponseSpec();
final List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, json);
assertNotNull(responses, "Responses");
BatchResponse response1 = responses.get(0);
Integer statusCode = response1.getStatusCode();
String msg = Strings.nullToEmpty(response1.getBody());
assertNotNull(statusCode, "First response status code");
assertTrue(
SC_OK == statusCode || SC_CONFLICT == statusCode
|| (SC_FORBIDDEN == statusCode && msg.contains("Cannot add or update a child row")),
"Status code: " + statusCode + ", message: " + msg);
if (SC_OK == statusCode) {
assertEquals(4, responses.size(), "Response size for OK response");
Integer statusCode4 = responses.get(3).getStatusCode();
assertNotNull(statusCode4, "Last OK response status code");
assertEquals(SC_OK, statusCode4, "Last OK response status code");
} else {
assertEquals(1, responses.size(), "Response size for failed response");
}
}
// Reset configuration fields
@AfterEach
public void tearDown() {
GlobalConfigurationHelper.resetAllDefaultGlobalConfigurations(this.requestSpec, this.responseSpec);
GlobalConfigurationHelper.verifyAllDefaultGlobalConfigurations(this.requestSpec, this.responseSpec);
}
}