From 633acf204be5e91df48a42ad0d250b00f11ce7ef Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Wed, 14 Jan 2026 20:56:26 +0100 Subject: [PATCH] FINERACT-2421: Allow reage preview API via Batch API --- .../command/CommandStrategyProvider.java | 11 +- .../batch/command/CommandStrategyUtils.java | 91 +++++- ...reviewByLoanExternalIdCommandStrategy.java | 95 ++++++ ...etReagePreviewByLoanIdCommandStrategy.java | 93 ++++++ .../service/reaging/LoanReAgingService.java | 3 +- .../command/CommandStrategyProviderTest.java | 21 +- .../command/CommandStrategyUtilsTest.java | 305 ++++++++++++++++++ ...ewByLoanExternalIdCommandStrategyTest.java | 277 ++++++++++++++++ ...agePreviewByLoanIdCommandStrategyTest.java | 273 ++++++++++++++++ 9 files changed, 1163 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java 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 ccc7d5a10..e024b35df 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 56f2db01c..71d781385 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 getQueryParameters(final String relativeUrl) { final String queryParameterStr = StringUtils.substringAfter(relativeUrl, "?"); @@ -47,7 +52,14 @@ public final class CommandStrategyUtils { final Map 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 + * 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 buildRequestFromQueryParameters(final Map queryParameters, final Class 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 000000000..eddb703f7 --- /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 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=&queryParam2=&.... + // Get the loan external id + final List pathParameters = Splitter.on('/').splitToList(relativeUrl); + final String loanExternalId = pathParameters.get(2); + Map 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 000000000..01d1f7b34 --- /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 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=&queryParam2=&.... + // Get the loan external id + final List pathParameters = Splitter.on('/').splitToList(relativeUrl); + final Long loanId = Long.parseLong(pathParameters.get(1)); + Map 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 84c1d22a4..9ad702ea0 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 c78b72d4f..9333ebddf 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 6f1394e24..b15fbf35e 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 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 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 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 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 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 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 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 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 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 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", "user@example.com"), + 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 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 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 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 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 result = CommandStrategyUtils.getQueryParameters(relativeUrl); + + // then + assertThat(result).isNotNull(); + assertThat(result).isEmpty(); + } + + @Test + public void testBuildRequestFromQueryParametersWithUrlEncodedValues() { + // given + Map 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 000000000..0b601d520 --- /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 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 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 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 toApiJsonSerializer; + + /** + * The Captor for ReAgePreviewRequest + */ + @Captor + private ArgumentCaptor 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 000000000..5d2e1c2ab --- /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 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 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 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 toApiJsonSerializer; + + /** + * The Captor for ReAgePreviewRequest + */ + @Captor + private ArgumentCaptor reAgePreviewRequestCaptor; + + /** + * The class under test. + */ + private final GetReagePreviewByLoanIdCommandStrategy subjectToTest; + + /** + * Constructor. + */ + TestContext() { + MockitoAnnotations.openMocks(this); + subjectToTest = new GetReagePreviewByLoanIdCommandStrategy(loanTransactionsApiResource, toApiJsonSerializer); + } + } +}