This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 633acf204b FINERACT-2421: Allow reage preview API via Batch API
633acf204b is described below
commit 633acf204be5e91df48a42ad0d250b00f11ce7ef
Author: Adam Saghy <[email protected]>
AuthorDate: Wed Jan 14 20:56:26 2026 +0100
FINERACT-2421: Allow reage preview API via Batch API
---
.../batch/command/CommandStrategyProvider.java | 11 +-
.../batch/command/CommandStrategyUtils.java | 91 +++++-
...eagePreviewByLoanExternalIdCommandStrategy.java | 95 +++++++
.../GetReagePreviewByLoanIdCommandStrategy.java | 93 +++++++
.../service/reaging/LoanReAgingService.java | 3 +-
.../batch/command/CommandStrategyProviderTest.java | 21 +-
.../batch/command/CommandStrategyUtilsTest.java | 305 +++++++++++++++++++++
...PreviewByLoanExternalIdCommandStrategyTest.java | 277 +++++++++++++++++++
...GetReagePreviewByLoanIdCommandStrategyTest.java | 273 ++++++++++++++++++
9 files changed, 1163 insertions(+), 6 deletions(-)
diff --git
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
index ccc7d5a107..e024b35df3 100644
---
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
+++
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
@@ -47,12 +47,12 @@ public class CommandStrategyProvider {
/**
* Regex pattern for specifying any number of query params or not specific
any query param
*/
- private static final String OPTIONAL_QUERY_PARAM_REGEX =
"(\\?(\\w+(?:\\=[\\w,]+|&)+)+)?";
+ private static final String OPTIONAL_QUERY_PARAM_REGEX =
"(\\?(\\w+=[^&]+)(?:&\\w+=[^&]+)*)?";
/**
* Regex pattern for specifying query params
*/
- private static final String MANDATORY_QUERY_PARAM_REGEX =
"(\\?(\\w+(?:\\=[\\w\\-,]+|&)+)+)";
+ private static final String MANDATORY_QUERY_PARAM_REGEX =
"(\\?(\\w+=[^&]+)(?:&\\w+=[^&]+)*)";
/**
* Regex pattern for specifying any query param that has key = 'command'
or not specific anything.
@@ -201,6 +201,13 @@ public class CommandStrategyProvider {
CommandContext.resource("v1\\/loans\\/external-id\\/" +
UUID_PARAM_REGEX + "\\/transactions\\/external-id\\/"
+ UUID_PARAM_REGEX +
OPTIONAL_QUERY_PARAM_REGEX).method(GET).build(),
"getLoanTransactionByExternalIdCommandStrategy");
+ commandStrategies.put(
+ CommandContext.resource("v1\\/loans\\/" + NUMBER_REGEX +
"\\/transactions\\/reage-preview" + OPTIONAL_QUERY_PARAM_REGEX)
+ .method(GET).build(),
+ "getReagePreviewByLoanIdCommandStrategy");
+ commandStrategies.put(CommandContext
+ .resource("v1\\/loans\\/external-id\\/" + UUID_PARAM_REGEX +
"\\/transactions\\/reage-preview" + OPTIONAL_QUERY_PARAM_REGEX)
+ .method(GET).build(),
"getReagePreviewByLoanExternalIdCommandStrategy");
commandStrategies.put(CommandContext.resource("v1\\/datatables\\/" +
ALPHANUMBERIC_WITH_UNDERSCORE_REGEX + "\\/" + NUMBER_REGEX)
.method(POST).build(), "createDatatableEntryCommandStrategy");
commandStrategies.put(CommandContext
diff --git
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
index 56f2db01cf..71d7813857 100644
---
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
+++
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
@@ -18,6 +18,11 @@
*/
package org.apache.fineract.batch.command;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
@@ -39,7 +44,7 @@ public final class CommandStrategyUtils {
*
* @param relativeUrl
* the relative URL
- * @return the query parameters in a map
+ * @return the query parameters in a map with URL-decoded values
*/
public static Map<String, String> getQueryParameters(final String
relativeUrl) {
final String queryParameterStr =
StringUtils.substringAfter(relativeUrl, "?");
@@ -47,7 +52,14 @@ public final class CommandStrategyUtils {
final Map<String, String> queryParametersMap = new HashMap<>();
for (String parameterStr : queryParametersArray) {
String[] keyValue = StringUtils.split(parameterStr, "=");
- queryParametersMap.put(keyValue[0], keyValue[1]);
+ String key = URLDecoder.decode(keyValue[0],
StandardCharsets.UTF_8);
+ String value = "";
+ if (keyValue.length > 1 && StringUtils.isNotEmpty(keyValue[1])) {
+ value = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
+ } else if (keyValue.length > 1) {
+ value = keyValue[1];
+ }
+ queryParametersMap.put(key, value);
}
return queryParametersMap;
}
@@ -86,4 +98,79 @@ public final class CommandStrategyUtils {
return m.matches();
}
+ /**
+ * Builds a request object from query parameters using reflection and the
builder pattern. This method automatically
+ * detects field types and converts string values to the appropriate type
(Integer, String, etc.).
+ *
+ * @param queryParameters
+ * map of query parameter names to their string values
+ * @param requestClass
+ * the class of the request object to build (must have a
builder() method)
+ * @param <T>
+ * the type of the request object
+ * @return the built request object with fields populated from query
parameters
+ * @throws RuntimeException
+ * if the request class doesn't have a builder or if
reflection fails
+ */
+ public static <T> T buildRequestFromQueryParameters(final Map<String,
String> queryParameters, final Class<T> requestClass) {
+ try {
+ // Get the builder
+ Method builderMethod = requestClass.getMethod("builder");
+ Object builder = builderMethod.invoke(null);
+ Class<?> builderClass = builder.getClass();
+
+ // Iterate through all declared fields of the request class
+ for (Field field : requestClass.getDeclaredFields()) {
+ String fieldName = field.getName();
+ String paramValue = queryParameters.get(fieldName);
+
+ // Skip if parameter is not present or is the serialVersionUID
field
+ if (paramValue == null ||
"serialVersionUID".equals(fieldName)) {
+ continue;
+ }
+
+ // Find the builder method for this field
+ Method builderSetter = builderClass.getMethod(fieldName,
field.getType());
+
+ // Convert the string value to the appropriate type and invoke
builder method
+ Object convertedValue = convertValue(paramValue,
field.getType());
+ builderSetter.invoke(builder, convertedValue);
+ }
+
+ // Call build() method to create the final object
+ Method buildMethod = builderClass.getMethod("build");
+ return requestClass.cast(buildMethod.invoke(builder));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to build request object from
query parameters", e);
+ }
+ }
+
+ /**
+ * Converts a string value to the target type.
+ *
+ * @param value
+ * the string value to convert
+ * @param targetType
+ * the target type
+ * @return the converted value
+ */
+ private static Object convertValue(final String value, final Class<?>
targetType) {
+ if (targetType == String.class) {
+ return value;
+ } else if (targetType == Integer.class || targetType == int.class) {
+ return Integer.parseInt(value);
+ } else if (targetType == Long.class || targetType == long.class) {
+ return Long.parseLong(value);
+ } else if (targetType == BigDecimal.class) {
+ return new BigDecimal(value);
+ } else if (targetType == Double.class || targetType == double.class) {
+ return Double.parseDouble(value);
+ } else if (targetType == Boolean.class || targetType == boolean.class)
{
+ return Boolean.parseBoolean(value);
+ } else {
+ // Default to string for unknown types
+ return value;
+ }
+ }
+
}
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java
new file mode 100644
index 0000000000..eddb703f79
--- /dev/null
+++
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java
@@ -0,0 +1,95 @@
+/**
+ * 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.batch.command.internal;
+
+import static
org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion;
+
+import com.google.common.base.Splitter;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.batch.command.CommandStrategy;
+import org.apache.fineract.batch.command.CommandStrategyUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * Implements {@link CommandStrategy} to calculate the reage preview for the
given term for a particular loan by its
+ * loan external id. It passes the contents of the body from the BatchRequest
to {@link LoanTransactionsApiResource} and
+ * gets back the response. This class will also catch any errors raised by
{@link LoanTransactionsApiResource} and map
+ * those errors to appropriate status codes in BatchResponse.
+ *
+ * @see CommandStrategy
+ * @see BatchRequest
+ * @see BatchResponse
+ */
+@Component
+@RequiredArgsConstructor
+public class GetReagePreviewByLoanExternalIdCommandStrategy implements
CommandStrategy {
+
+ /**
+ * Loan transactions api resource {@link LoanTransactionsApiResource}.
+ */
+ private final LoanTransactionsApiResource loanTransactionsApiResource;
+
+ /**
+ * The toApiJsonSerializer to convert json to object
+ */
+ private final DefaultToApiJsonSerializer<LoanScheduleData>
toApiJsonSerializer;
+
+ @Override
+ public BatchResponse execute(final BatchRequest request, final UriInfo
uriInfo) {
+ final BatchResponse response = new BatchResponse();
+
+ response.setRequestId(request.getRequestId());
+ response.setHeaders(request.getHeaders());
+
+ final String relativeUrl = relativeUrlWithoutVersion(request);
+
+ // Expected pattern - loans/external-id/" + UUID_PARAM_REGEX +
+ //
"/transactions/reage-preview?queryParam1=<blah>&queryParam2=<blah>&....
+ // Get the loan external id
+ final List<String> pathParameters =
Splitter.on('/').splitToList(relativeUrl);
+ final String loanExternalId = pathParameters.get(2);
+ Map<String, String> queryParameters = new HashMap<>();
+ if (relativeUrl.indexOf('?') > 0) {
+ queryParameters =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+ }
+
+ // Build ReAgePreviewRequest from query parameters using generic
utility
+ final ReAgePreviewRequest reAgePreviewRequest =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParameters,
+ ReAgePreviewRequest.class);
+
+ // Calls 'previewReAgeSchedule' function from
'loanTransactionsApiResource' using external id
+ response.setStatusCode(HttpStatus.SC_OK);
+ // Sets the body of the response after getting reage preview
+ response.setBody(
+
toApiJsonSerializer.serialize(loanTransactionsApiResource.previewReAgeSchedule(loanExternalId,
reAgePreviewRequest)));
+
+ return response;
+ }
+}
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java
new file mode 100644
index 0000000000..01d1f7b344
--- /dev/null
+++
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java
@@ -0,0 +1,93 @@
+/**
+ * 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.batch.command.internal;
+
+import static
org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion;
+
+import com.google.common.base.Splitter;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.batch.command.CommandStrategy;
+import org.apache.fineract.batch.command.CommandStrategyUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * Implements {@link CommandStrategy} to calculate the reage preview for the
given term for a particular loan by its
+ * loan id. It passes the contents of the body from the BatchRequest to {@link
LoanTransactionsApiResource} and gets
+ * back the response. This class will also catch any errors raised by {@link
LoanTransactionsApiResource} and map those
+ * errors to appropriate status codes in BatchResponse.
+ *
+ * @see CommandStrategy
+ * @see BatchRequest
+ * @see BatchResponse
+ */
+@Component
+@RequiredArgsConstructor
+public class GetReagePreviewByLoanIdCommandStrategy implements CommandStrategy
{
+
+ /**
+ * Loan transactions api resource {@link LoanTransactionsApiResource}.
+ */
+ private final LoanTransactionsApiResource loanTransactionsApiResource;
+
+ /**
+ * The toApiJsonSerializer to convert json to object
+ */
+ private final DefaultToApiJsonSerializer<LoanScheduleData>
toApiJsonSerializer;
+
+ @Override
+ public BatchResponse execute(final BatchRequest request, final UriInfo
uriInfo) {
+ final BatchResponse response = new BatchResponse();
+
+ response.setRequestId(request.getRequestId());
+ response.setHeaders(request.getHeaders());
+
+ final String relativeUrl = relativeUrlWithoutVersion(request);
+
+ // Expected pattern - loans/" + NUMBER +
"/transactions/reage-preview?queryParam1=<blah>&queryParam2=<blah>&....
+ // Get the loan external id
+ final List<String> pathParameters =
Splitter.on('/').splitToList(relativeUrl);
+ final Long loanId = Long.parseLong(pathParameters.get(1));
+ Map<String, String> queryParameters = new HashMap<>();
+ if (relativeUrl.indexOf('?') > 0) {
+ queryParameters =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+ }
+
+ // Build ReAgePreviewRequest from query parameters using generic
utility
+ final ReAgePreviewRequest reAgePreviewRequest =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParameters,
+ ReAgePreviewRequest.class);
+
+ // Calls 'previewReAgeSchedule' function from
'loanTransactionsApiResource' using external id
+ response.setStatusCode(HttpStatus.SC_OK);
+ // Sets the body of the response after getting reage preview
+
response.setBody(toApiJsonSerializer.serialize(loanTransactionsApiResource.previewReAgeSchedule(loanId,
reAgePreviewRequest)));
+
+ return response;
+ }
+}
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
index 84c1d22a46..9ad702ea0d 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
@@ -77,6 +77,7 @@ import
org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactio
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -129,7 +130,7 @@ public class LoanReAgingService {
.with(changes).build();
}
- @Transactional(readOnly = true)
+ @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public LoanScheduleData previewReAge(final Long loanId, final String
loanExternalId, final ReAgePreviewRequest reAgePreviewRequest) {
final Loan loan = loanId != null ? loanAssembler.assembleFrom(loanId)
:
loanAssembler.assembleFrom(ExternalIdFactory.produce(loanExternalId), false);
diff --git
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
index c78b72d4fa..9333ebddf2 100644
---
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
+++
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
@@ -52,6 +52,8 @@ import
org.apache.fineract.batch.command.internal.GetLoanByExternalIdCommandStra
import org.apache.fineract.batch.command.internal.GetLoanByIdCommandStrategy;
import
org.apache.fineract.batch.command.internal.GetLoanTransactionByExternalIdCommandStrategy;
import
org.apache.fineract.batch.command.internal.GetLoanTransactionByIdCommandStrategy;
+import
org.apache.fineract.batch.command.internal.GetReagePreviewByLoanExternalIdCommandStrategy;
+import
org.apache.fineract.batch.command.internal.GetReagePreviewByLoanIdCommandStrategy;
import
org.apache.fineract.batch.command.internal.LoanStateTransistionsByExternalIdCommandStrategy;
import
org.apache.fineract.batch.command.internal.ModifyLoanApplicationCommandStrategy;
import org.apache.fineract.batch.command.internal.UnknownCommandStrategy;
@@ -206,7 +208,24 @@ public class CommandStrategyProviderTest {
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/interest-pauses",
HttpMethod.POST,
"createLoanInterestPauseByExternalIdCommandStrategy",
mock(CommandStrategy.class)),
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/interest-pauses/123",
HttpMethod.PUT,
- "updateLoanInterestPauseByExternalIdCommandStrategy",
mock(CommandStrategy.class)));
+ "updateLoanInterestPauseByExternalIdCommandStrategy",
mock(CommandStrategy.class)),
+ Arguments.of("loans/123/transactions/reage-preview",
HttpMethod.GET, "getReagePreviewByLoanIdCommandStrategy",
+ mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+
Arguments.of("loans/123/transactions/reage-preview?frequencyNumber=1&frequencyType=MONTHS",
HttpMethod.GET,
+ "getReagePreviewByLoanIdCommandStrategy",
mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+ Arguments.of(
+
"loans/123/transactions/reage-preview?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&numberOfInstallments=6",
+ HttpMethod.GET,
"getReagePreviewByLoanIdCommandStrategy",
mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/transactions/reage-preview",
HttpMethod.GET,
+ "getReagePreviewByLoanExternalIdCommandStrategy",
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)),
+ Arguments.of(
+
"loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/transactions/reage-preview?frequencyNumber=2&frequencyType=WEEKS",
+ HttpMethod.GET,
"getReagePreviewByLoanExternalIdCommandStrategy",
+
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)),
+ Arguments.of(
+
"loans/external-id/0083477d-ea2a-45a4-a244-cb79a9ecf741/transactions/reage-preview?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&numberOfInstallments=6",
+ HttpMethod.GET,
"getReagePreviewByLoanExternalIdCommandStrategy",
+
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)));
}
/**
diff --git
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
index 6f1394e240..b15fbf35e4 100644
---
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
+++
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
@@ -19,9 +19,21 @@
package org.apache.fineract.batch.command;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
import org.apache.fineract.batch.domain.BatchRequest;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
class CommandStrategyUtilsTest {
@@ -90,4 +102,297 @@ class CommandStrategyUtilsTest {
// then
assertThat(result).isEqualTo("clients/123?command=action&something=else");
}
+
+ // Tests for buildRequestFromQueryParameters method
+
+ private static Stream<Arguments> typeConversionTestCases() {
+ return Stream.of(Arguments.of("stringField", "testValue", (Object)
"testValue"), Arguments.of("integerField", "42", 42),
+ Arguments.of("longField", "9876543210", 9876543210L),
Arguments.of("bigDecimalField", "123.456", new BigDecimal("123.456")),
+ Arguments.of("bigDecimalField",
"0.1000000000000000055511151231257827021181583404541015625",
+ new
BigDecimal("0.1000000000000000055511151231257827021181583404541015625")),
+ Arguments.of("doubleField", "3.14", 3.14),
Arguments.of("booleanField", "true", true),
+ Arguments.of("booleanField", "false", false),
Arguments.of("booleanField", "TRUE", true));
+ }
+
+ @ParameterizedTest
+ @MethodSource("typeConversionTestCases")
+ public void testBuildRequestFromQueryParametersTypeConversion(String
fieldName, String value, Object expectedValue) {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put(fieldName, value);
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ Object actualValue = getFieldValue(result, fieldName);
+ if (expectedValue instanceof BigDecimal) {
+ assertThat((BigDecimal)
actualValue).isEqualByComparingTo((BigDecimal) expectedValue);
+ } else {
+ assertThat(actualValue).isEqualTo(expectedValue);
+ }
+ }
+
+ private static Stream<Arguments> invalidTypeConversionTestCases() {
+ return Stream.of(Arguments.of("integerField", "notANumber"),
Arguments.of("longField", "notANumber"),
+ Arguments.of("bigDecimalField", "notANumber"),
Arguments.of("doubleField", "notANumber"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidTypeConversionTestCases")
+ public void
testBuildRequestFromQueryParametersInvalidTypeConversion(String fieldName,
String invalidValue) {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put(fieldName, invalidValue);
+
+ // when/then
+ assertThatThrownBy(() ->
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class))
+
.isInstanceOf(RuntimeException.class).hasMessageContaining("Failed to build
request object from query parameters")
+ .hasCauseInstanceOf(NumberFormatException.class);
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersWithAllFieldTypes() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put("stringField", "testValue");
+ queryParams.put("integerField", "42");
+ queryParams.put("longField", "9876543210");
+ queryParams.put("bigDecimalField", "3.14");
+ queryParams.put("doubleField", "2.71");
+ queryParams.put("booleanField", "true");
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getStringField()).isEqualTo("testValue");
+ assertThat(result.getIntegerField()).isEqualTo(42);
+ assertThat(result.getLongField()).isEqualTo(9876543210L);
+ assertThat(result.getBigDecimalField()).isEqualByComparingTo(new
BigDecimal("3.14"));
+ assertThat(result.getDoubleField()).isEqualTo(2.71);
+ assertThat(result.getBooleanField()).isTrue();
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersWithMissingFields() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put("stringField", "testValue");
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getStringField()).isEqualTo("testValue");
+ assertThat(result.getIntegerField()).isNull();
+ assertThat(result.getLongField()).isNull();
+ assertThat(result.getBigDecimalField()).isNull();
+ assertThat(result.getDoubleField()).isNull();
+ assertThat(result.getBooleanField()).isNull();
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersWithEmptyMap() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getStringField()).isNull();
+ assertThat(result.getIntegerField()).isNull();
+ assertThat(result.getLongField()).isNull();
+ assertThat(result.getBigDecimalField()).isNull();
+ assertThat(result.getDoubleField()).isNull();
+ assertThat(result.getBooleanField()).isNull();
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersSkipsSerialVersionUID() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put("serialVersionUID", "12345");
+ queryParams.put("stringField", "testValue");
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getStringField()).isEqualTo("testValue");
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersWithClassWithoutBuilder() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put("field", "value");
+
+ // when/then
+ assertThatThrownBy(() ->
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
ClassWithoutBuilder.class))
+
.isInstanceOf(RuntimeException.class).hasMessageContaining("Failed to build
request object from query parameters");
+ }
+
+ private Object getFieldValue(TestRequest request, String fieldName) {
+ switch (fieldName) {
+ case "stringField":
+ return request.getStringField();
+ case "integerField":
+ return request.getIntegerField();
+ case "longField":
+ return request.getLongField();
+ case "bigDecimalField":
+ return request.getBigDecimalField();
+ case "doubleField":
+ return request.getDoubleField();
+ case "booleanField":
+ return request.getBooleanField();
+ default:
+ return null;
+ }
+ }
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ static class TestRequest {
+
+ private String stringField;
+ private Integer integerField;
+ private Long longField;
+ private BigDecimal bigDecimalField;
+ private Double doubleField;
+ private Boolean booleanField;
+ }
+
+ static class ClassWithoutBuilder {
+
+ private String field;
+
+ ClassWithoutBuilder(String field) {
+ this.field = field;
+ }
+ }
+
+ // Tests for getQueryParameters method with URL decoding
+
+ private static Stream<Arguments> urlDecodingTestCases() {
+ return Stream.of(
+ // URL-encoded forward slashes
+ Arguments.of("loans/123?dateFormat=MM%2Fdd%2Fyyyy",
"dateFormat", "MM/dd/yyyy"),
+ Arguments.of("loans/123?startDate=02%2F05%2F2026",
"startDate", "02/05/2026"),
+ // URL-encoded spaces
+ Arguments.of("loans/123?name=John%20Doe", "name", "John Doe"),
+ // URL-encoded special characters
+ Arguments.of("loans/123?email=user%40example.com", "email",
"[email protected]"),
+ Arguments.of("loans/123?query=hello%26world", "query",
"hello&world"),
+ // URL-encoded percentage sign
+ Arguments.of("loans/123?discount=50%25", "discount", "50%"),
+ // Non-encoded values (should remain unchanged)
+ Arguments.of("loans/123?locale=en_US", "locale", "en_US"),
+ Arguments.of("loans/123?frequencyType=MONTHS",
"frequencyType", "MONTHS"),
+ Arguments.of("loans/123?frequencyNumber=1", "frequencyNumber",
"1"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("urlDecodingTestCases")
+ public void testGetQueryParametersWithUrlDecoding(String relativeUrl,
String expectedKey, String expectedValue) {
+ // when
+ Map<String, String> result =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result).containsKey(expectedKey);
+ assertThat(result.get(expectedKey)).isEqualTo(expectedValue);
+ }
+
+ @Test
+ public void testGetQueryParametersWithMultipleUrlEncodedParams() {
+ // given
+ String relativeUrl =
"loans/123?dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&locale=en_US&name=John%20Doe";
+
+ // when
+ Map<String, String> result =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result).hasSize(4);
+ assertThat(result.get("dateFormat")).isEqualTo("MM/dd/yyyy");
+ assertThat(result.get("startDate")).isEqualTo("02/05/2026");
+ assertThat(result.get("locale")).isEqualTo("en_US");
+ assertThat(result.get("name")).isEqualTo("John Doe");
+ }
+
+ @Test
+ public void testGetQueryParametersWithComplexUrlEncodedQuery() {
+ // given - using the actual sample URL from the requirement
+ String relativeUrl =
"loans/external-id/0083477d-ea2a-45a4-a244-cb79a9ecf741/transactions/reage-preview"
+ +
"?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy"
+ + "&startDate=02%2F05%2F2026&numberOfInstallments=6";
+
+ // when
+ Map<String, String> result =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result).hasSize(6);
+ assertThat(result.get("frequencyType")).isEqualTo("MONTHS");
+ assertThat(result.get("locale")).isEqualTo("en_US");
+ assertThat(result.get("frequencyNumber")).isEqualTo("1");
+ assertThat(result.get("dateFormat")).isEqualTo("MM/dd/yyyy");
+ assertThat(result.get("startDate")).isEqualTo("02/05/2026");
+ assertThat(result.get("numberOfInstallments")).isEqualTo("6");
+ }
+
+ @Test
+ public void testGetQueryParametersWithEmptyValue() {
+ // given
+ String relativeUrl = "loans/123?param1=value1¶m2=¶m3=value3";
+
+ // when
+ Map<String, String> result =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result).hasSize(3);
+ assertThat(result.get("param1")).isEqualTo("value1");
+ assertThat(result.get("param2")).isEqualTo("");
+ assertThat(result.get("param3")).isEqualTo("value3");
+ }
+
+ @Test
+ public void testGetQueryParametersWithNoQueryParams() {
+ // given
+ String relativeUrl = "loans/123";
+
+ // when
+ Map<String, String> result =
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void testBuildRequestFromQueryParametersWithUrlEncodedValues() {
+ // given
+ Map<String, String> queryParams = new HashMap<>();
+ queryParams.put("stringField", "Hello World"); // Already decoded by
getQueryParameters
+ queryParams.put("integerField", "42");
+
+ // when
+ TestRequest result =
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams,
TestRequest.class);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getStringField()).isEqualTo("Hello World");
+ assertThat(result.getIntegerField()).isEqualTo(42);
+ }
}
diff --git
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
new file mode 100644
index 0000000000..0b601d520c
--- /dev/null
+++
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
@@ -0,0 +1,277 @@
+/**
+ * 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.batch.command.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Test class for {@link GetReagePreviewByLoanExternalIdCommandStrategy}.
+ */
+public class GetReagePreviewByLoanExternalIdCommandStrategyTest {
+
+ /**
+ * The query parameter provider.
+ *
+ * @return Arguments.
+ */
+ private static Stream<Arguments> provideQueryParameters() {
+ return Stream.of(Arguments.of(1, "MONTHS", "2024-01-15", 12, "dd MMMM
yyyy", "en"),
+ Arguments.of(2, "WEEKS", "2024-02-01", 24, "yyyy-MM-dd",
"en_US"),
+ Arguments.of(3, "DAYS", "2024-03-10", 6, "dd/MM/yyyy",
"en_GB"));
+ }
+
+ /**
+ * Test {@link GetReagePreviewByLoanExternalIdCommandStrategy#execute}
with wrong parameter names.
+ */
+ @Test
+ public void testExecuteWithWrongParameterNames() {
+ // given
+ final TestContext testContext = new TestContext();
+
+ final String loanExternalId = UUID.randomUUID().toString();
+ // Build request with WRONG parameter names
+ final BatchRequest request =
getBatchRequestWithWrongParameterNames(loanExternalId);
+
+ // Mock LoanScheduleData since it doesn't have a default constructor
+ final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+ final String responseBody = "{\"periods\":[]}";
+
+
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanExternalId),
any(ReAgePreviewRequest.class)))
+ .willReturn(loanScheduleData);
+
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+ // when
+ final BatchResponse response =
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+
+ // Verify the API resource was called
+
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanExternalId),
+ testContext.reAgePreviewRequestCaptor.capture());
+
+ // Verify the serializer was invoked
+
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+ // Verify that wrong parameter names result in null values
+ final ReAgePreviewRequest capturedRequest =
testContext.reAgePreviewRequestCaptor.getValue();
+ assertThat(capturedRequest.getFrequencyNumber()).isNull(); //
frequencyNo was sent, not frequencyNumber
+ assertThat(capturedRequest.getFrequencyType()).isNull(); // freqType
was sent, not frequencyType
+ assertThat(capturedRequest.getStartDate()).isNull(); // start was
sent, not startDate
+ assertThat(capturedRequest.getNumberOfInstallments()).isNull(); //
installments was sent, not
+ //
numberOfInstallments
+ assertThat(capturedRequest.getDateFormat()).isNull(); // format was
sent, not dateFormat
+ assertThat(capturedRequest.getLocale()).isNull(); // lang was sent,
not locale
+ }
+
+ /**
+ * Test {@link GetReagePreviewByLoanExternalIdCommandStrategy#execute}
happy path scenario.
+ */
+ @ParameterizedTest
+ @MethodSource("provideQueryParameters")
+ public void testExecuteSuccessScenario(final Integer frequencyNumber,
final String frequencyType, final String startDate,
+ final Integer numberOfInstallments, final String dateFormat, final
String locale) {
+ // given
+ final TestContext testContext = new TestContext();
+
+ final String loanExternalId = UUID.randomUUID().toString();
+ final BatchRequest request = getBatchRequest(loanExternalId,
frequencyNumber, frequencyType, startDate, numberOfInstallments,
+ dateFormat, locale);
+
+ // Mock LoanScheduleData since it doesn't have a default constructor
+ final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+ final String responseBody = "{\"periods\":[]}";
+
+
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanExternalId),
any(ReAgePreviewRequest.class)))
+ .willReturn(loanScheduleData);
+
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+ // when
+ final BatchResponse response =
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+ assertThat(response.getRequestId()).isEqualTo(request.getRequestId());
+ assertThat(response.getHeaders()).isEqualTo(request.getHeaders());
+ assertThat(response.getBody()).isEqualTo(responseBody);
+
+ // Verify the API resource was called with correct parameters
+
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanExternalId),
+ testContext.reAgePreviewRequestCaptor.capture());
+
+ // Verify the serializer was invoked with the returned LoanScheduleData
+
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+ // Verify the ReAgePreviewRequest was built correctly from query
parameters
+ final ReAgePreviewRequest capturedRequest =
testContext.reAgePreviewRequestCaptor.getValue();
+
assertThat(capturedRequest.getFrequencyNumber()).isEqualTo(frequencyNumber);
+
assertThat(capturedRequest.getFrequencyType()).isEqualTo(frequencyType);
+ assertThat(capturedRequest.getStartDate()).isEqualTo(startDate);
+
assertThat(capturedRequest.getNumberOfInstallments()).isEqualTo(numberOfInstallments);
+ assertThat(capturedRequest.getDateFormat()).isEqualTo(dateFormat);
+ assertThat(capturedRequest.getLocale()).isEqualTo(locale);
+ }
+
+ /**
+ * Creates and returns a batch request with the given parameters.
+ *
+ * @param loanExternalId
+ * the loan external id
+ * @param frequencyNumber
+ * the frequency number
+ * @param frequencyType
+ * the frequency type
+ * @param startDate
+ * the start date
+ * @param numberOfInstallments
+ * the number of installments
+ * @param dateFormat
+ * the date format
+ * @param locale
+ * the locale
+ * @return BatchRequest
+ */
+ private BatchRequest getBatchRequest(final String loanExternalId, final
Integer frequencyNumber, final String frequencyType,
+ final String startDate, final Integer numberOfInstallments, final
String dateFormat, final String locale) {
+
+ final BatchRequest br = new BatchRequest();
+ String relativeUrl = "loans/external-id/" + loanExternalId +
"/transactions/reage-preview";
+
+ Set<String> queryParams = new HashSet<>();
+ queryParams.add("frequencyNumber=" + frequencyNumber);
+ queryParams.add("frequencyType=" + frequencyType);
+ queryParams.add("startDate=" + startDate);
+ queryParams.add("numberOfInstallments=" + numberOfInstallments);
+ queryParams.add("dateFormat=" + dateFormat);
+ queryParams.add("locale=" + locale);
+
+ relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+ br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setRelativeUrl(relativeUrl);
+ br.setMethod(HttpMethod.GET);
+ br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setBody("{}");
+
+ return br;
+ }
+
+ /**
+ * Creates and returns a batch request with WRONG parameter names to test
validation.
+ *
+ * @param loanExternalId
+ * the loan external id
+ * @return BatchRequest
+ */
+ private BatchRequest getBatchRequestWithWrongParameterNames(final String
loanExternalId) {
+
+ final BatchRequest br = new BatchRequest();
+ String relativeUrl = "loans/external-id/" + loanExternalId +
"/transactions/reage-preview";
+
+ Set<String> queryParams = new HashSet<>();
+ // Using wrong parameter names
+ queryParams.add("frequencyNo=1"); // should be frequencyNumber
+ queryParams.add("freqType=MONTHS"); // should be frequencyType
+ queryParams.add("start=2024-01-15"); // should be startDate
+ queryParams.add("installments=12"); // should be numberOfInstallments
+ queryParams.add("format=dd MMMM yyyy"); // should be dateFormat
+ queryParams.add("lang=en"); // should be locale
+
+ relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+ br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setRelativeUrl(relativeUrl);
+ br.setMethod(HttpMethod.GET);
+ br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setBody("{}");
+
+ return br;
+ }
+
+ /**
+ * Private test context class used since testng runs in parallel to avoid
state between tests
+ */
+ private static class TestContext {
+
+ /**
+ * The Mock UriInfo
+ */
+ @Mock
+ private UriInfo uriInfo;
+
+ /**
+ * The Mock {@link LoanTransactionsApiResource}
+ */
+ @Mock
+ private LoanTransactionsApiResource loanTransactionsApiResource;
+
+ /**
+ * The Mock {@link DefaultToApiJsonSerializer}
+ */
+ @Mock
+ private DefaultToApiJsonSerializer<LoanScheduleData>
toApiJsonSerializer;
+
+ /**
+ * The Captor for ReAgePreviewRequest
+ */
+ @Captor
+ private ArgumentCaptor<ReAgePreviewRequest> reAgePreviewRequestCaptor;
+
+ /**
+ * The class under test.
+ */
+ private final GetReagePreviewByLoanExternalIdCommandStrategy
subjectToTest;
+
+ /**
+ * Constructor.
+ */
+ TestContext() {
+ MockitoAnnotations.openMocks(this);
+ subjectToTest = new
GetReagePreviewByLoanExternalIdCommandStrategy(loanTransactionsApiResource,
toApiJsonSerializer);
+ }
+ }
+}
diff --git
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
new file mode 100644
index 0000000000..5d2e1c2ab9
--- /dev/null
+++
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
@@ -0,0 +1,273 @@
+/**
+ * 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.batch.command.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Test class for {@link GetReagePreviewByLoanIdCommandStrategy}.
+ */
+public class GetReagePreviewByLoanIdCommandStrategyTest {
+
+ /**
+ * The query parameter provider.
+ *
+ * @return Arguments.
+ */
+ private static Stream<Arguments> provideQueryParameters() {
+ return Stream.of(Arguments.of(1L, 1, "MONTHS", "2024-01-15", 12, "dd
MMMM yyyy", "en"),
+ Arguments.of(2L, 2, "WEEKS", "2024-02-01", 24, "yyyy-MM-dd",
"en_US"),
+ Arguments.of(3L, 3, "DAYS", "2024-03-10", 6, "dd/MM/yyyy",
"en_GB"));
+ }
+
+ /**
+ * Test {@link GetReagePreviewByLoanIdCommandStrategy#execute} with wrong
parameter names.
+ */
+ @Test
+ public void testExecuteWithWrongParameterNames() {
+ // given
+ final TestContext testContext = new TestContext();
+
+ final Long loanId = 1L;
+ // Build request with WRONG parameter names
+ final BatchRequest request =
getBatchRequestWithWrongParameterNames(loanId);
+
+ // Mock LoanScheduleData since it doesn't have a default constructor
+ final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+ final String responseBody = "{\"periods\":[]}";
+
+
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanId),
any(ReAgePreviewRequest.class)))
+ .willReturn(loanScheduleData);
+
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+ // when
+ final BatchResponse response =
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+
+ // Verify the API resource was called
+
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanId),
testContext.reAgePreviewRequestCaptor.capture());
+
+ // Verify the serializer was invoked
+
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+ // Verify that wrong parameter names result in null values
+ final ReAgePreviewRequest capturedRequest =
testContext.reAgePreviewRequestCaptor.getValue();
+ assertThat(capturedRequest.getFrequencyNumber()).isNull(); //
frequencyNo was sent, not frequencyNumber
+ assertThat(capturedRequest.getFrequencyType()).isNull(); // freqType
was sent, not frequencyType
+ assertThat(capturedRequest.getStartDate()).isNull(); // start was
sent, not startDate
+ assertThat(capturedRequest.getNumberOfInstallments()).isNull(); //
installments was sent, not
+ //
numberOfInstallments
+ assertThat(capturedRequest.getDateFormat()).isNull(); // format was
sent, not dateFormat
+ assertThat(capturedRequest.getLocale()).isNull(); // lang was sent,
not locale
+ }
+
+ /**
+ * Test {@link GetReagePreviewByLoanIdCommandStrategy#execute} happy path
scenario.
+ */
+ @ParameterizedTest
+ @MethodSource("provideQueryParameters")
+ public void testExecuteSuccessScenario(final Long loanId, final Integer
frequencyNumber, final String frequencyType,
+ final String startDate, final Integer numberOfInstallments, final
String dateFormat, final String locale) {
+ // given
+ final TestContext testContext = new TestContext();
+
+ final BatchRequest request = getBatchRequest(loanId, frequencyNumber,
frequencyType, startDate, numberOfInstallments, dateFormat,
+ locale);
+
+ // Mock LoanScheduleData since it doesn't have a default constructor
+ final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+ final String responseBody = "{\"periods\":[]}";
+
+
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanId),
any(ReAgePreviewRequest.class)))
+ .willReturn(loanScheduleData);
+
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+ // when
+ final BatchResponse response =
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+ assertThat(response.getRequestId()).isEqualTo(request.getRequestId());
+ assertThat(response.getHeaders()).isEqualTo(request.getHeaders());
+ assertThat(response.getBody()).isEqualTo(responseBody);
+
+ // Verify the API resource was called with correct parameters
+
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanId),
testContext.reAgePreviewRequestCaptor.capture());
+
+ // Verify the serializer was invoked with the returned LoanScheduleData
+
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+ // Verify the ReAgePreviewRequest was built correctly from query
parameters
+ final ReAgePreviewRequest capturedRequest =
testContext.reAgePreviewRequestCaptor.getValue();
+
assertThat(capturedRequest.getFrequencyNumber()).isEqualTo(frequencyNumber);
+
assertThat(capturedRequest.getFrequencyType()).isEqualTo(frequencyType);
+ assertThat(capturedRequest.getStartDate()).isEqualTo(startDate);
+
assertThat(capturedRequest.getNumberOfInstallments()).isEqualTo(numberOfInstallments);
+ assertThat(capturedRequest.getDateFormat()).isEqualTo(dateFormat);
+ assertThat(capturedRequest.getLocale()).isEqualTo(locale);
+ }
+
+ /**
+ * Creates and returns a batch request with the given parameters.
+ *
+ * @param loanId
+ * the loan id
+ * @param frequencyNumber
+ * the frequency number
+ * @param frequencyType
+ * the frequency type
+ * @param startDate
+ * the start date
+ * @param numberOfInstallments
+ * the number of installments
+ * @param dateFormat
+ * the date format
+ * @param locale
+ * the locale
+ * @return BatchRequest
+ */
+ private BatchRequest getBatchRequest(final Long loanId, final Integer
frequencyNumber, final String frequencyType,
+ final String startDate, final Integer numberOfInstallments, final
String dateFormat, final String locale) {
+
+ final BatchRequest br = new BatchRequest();
+ String relativeUrl = "loans/" + loanId + "/transactions/reage-preview";
+
+ Set<String> queryParams = new HashSet<>();
+ queryParams.add("frequencyNumber=" + frequencyNumber);
+ queryParams.add("frequencyType=" + frequencyType);
+ queryParams.add("startDate=" + startDate);
+ queryParams.add("numberOfInstallments=" + numberOfInstallments);
+ queryParams.add("dateFormat=" + dateFormat);
+ queryParams.add("locale=" + locale);
+
+ relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+ br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setRelativeUrl(relativeUrl);
+ br.setMethod(HttpMethod.GET);
+ br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setBody("{}");
+
+ return br;
+ }
+
+ /**
+ * Creates and returns a batch request with WRONG parameter names to test
validation.
+ *
+ * @param loanId
+ * the loan id
+ * @return BatchRequest
+ */
+ private BatchRequest getBatchRequestWithWrongParameterNames(final Long
loanId) {
+
+ final BatchRequest br = new BatchRequest();
+ String relativeUrl = "loans/" + loanId + "/transactions/reage-preview";
+
+ Set<String> queryParams = new HashSet<>();
+ // Using wrong parameter names
+ queryParams.add("frequencyNo=1"); // should be frequencyNumber
+ queryParams.add("freqType=MONTHS"); // should be frequencyType
+ queryParams.add("start=2024-01-15"); // should be startDate
+ queryParams.add("installments=12"); // should be numberOfInstallments
+ queryParams.add("format=dd MMMM yyyy"); // should be dateFormat
+ queryParams.add("lang=en"); // should be locale
+
+ relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+ br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setRelativeUrl(relativeUrl);
+ br.setMethod(HttpMethod.GET);
+ br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+ br.setBody("{}");
+
+ return br;
+ }
+
+ /**
+ * Private test context class used since testng runs in parallel to avoid
state between tests
+ */
+ private static class TestContext {
+
+ /**
+ * The Mock UriInfo
+ */
+ @Mock
+ private UriInfo uriInfo;
+
+ /**
+ * The Mock {@link LoanTransactionsApiResource}
+ */
+ @Mock
+ private LoanTransactionsApiResource loanTransactionsApiResource;
+
+ /**
+ * The Mock {@link DefaultToApiJsonSerializer}
+ */
+ @Mock
+ private DefaultToApiJsonSerializer<LoanScheduleData>
toApiJsonSerializer;
+
+ /**
+ * The Captor for ReAgePreviewRequest
+ */
+ @Captor
+ private ArgumentCaptor<ReAgePreviewRequest> reAgePreviewRequestCaptor;
+
+ /**
+ * The class under test.
+ */
+ private final GetReagePreviewByLoanIdCommandStrategy subjectToTest;
+
+ /**
+ * Constructor.
+ */
+ TestContext() {
+ MockitoAnnotations.openMocks(this);
+ subjectToTest = new
GetReagePreviewByLoanIdCommandStrategy(loanTransactionsApiResource,
toApiJsonSerializer);
+ }
+ }
+}