KRYSTALM7 commented on code in PR #171:
URL:
https://github.com/apache/fineract-loan-origination/pull/171#discussion_r4293568027
##########
src/test/java/org/apache/fineract/los/exception/GlobalExceptionHandlerTest.java:
##########
@@ -20,19 +20,335 @@
package org.apache.fineract.los.exception;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import jakarta.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.stream.Stream;
+import org.apache.fineract.los.api.ApprovalController;
+import org.apache.fineract.los.api.CustomerLoanApplicationController;
+import org.apache.fineract.los.api.LoanApplicationController;
+import org.apache.fineract.los.domain.LoanApplication;
import org.apache.fineract.los.domain.enums.LoanApplicationStatus;
+import org.apache.fineract.los.dto.request.CreateLoanApplicationRequest;
+import org.apache.fineract.los.repository.ApprovalStageRepository;
+import org.apache.fineract.los.service.ApprovalWorkflowService;
+import org.apache.fineract.los.service.CreditScoringService;
+import org.apache.fineract.los.service.LoanApplicationService;
import org.apache.fineract.los.statemachine.LoanStateTransitionException;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import
org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import
org.springframework.web.method.annotation.HandlerMethodValidationException;
+import org.springframework.web.server.ResponseStatusException;
-/** Unit tests for {@link GlobalExceptionHandler}. */
-@DisplayName("GlobalExceptionHandler")
+@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
- private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
+ private static final String APPLICATIONS = "/api/v1/loan-applications";
+ private static final String APPLICATION_REF = "LOS-2026-00001";
+ private static final String VALID_REQUEST =
+ """
+ {
+ "requestedAmount": 10000,
+ "currency": "USD",
+ "loanPurpose": "BUSINESS",
+ "tenorMonths": 12,
+ "fineractLoanProductId": 1,
+ "applicant": {
+ "fullName": "Test Applicant",
+ "nationalId": "TEST-001",
+ "monthlyIncome": 5000
+ }
+ }
+ """;
+
+ @Mock private LoanApplicationService loanApplicationService;
+ @Mock private CreditScoringService creditScoringService;
+ @Mock private ApprovalWorkflowService approvalWorkflowService;
+ @Mock private ApprovalStageRepository approvalStageRepository;
+
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc =
+ MockMvcBuilders.standaloneSetup(
+ new LoanApplicationController(
+ loanApplicationService, creditScoringService,
approvalWorkflowService),
+ new CustomerLoanApplicationController(
+ loanApplicationService, creditScoringService,
approvalStageRepository),
+ new ApprovalController(
+ approvalWorkflowService, approvalStageRepository,
loanApplicationService),
+ new InvalidReturnValueController())
+ .setCustomArgumentResolvers(new
AuthenticationPrincipalArgumentResolver())
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ }
+
+ @ParameterizedTest(name = "{index}: {1}")
+ @MethodSource("invalidLoanRequests")
+ void beanValidationReturnsBadRequest(final String body, final String
expectedMessage)
+ throws Exception {
+ expectBadRequest(APPLICATIONS, body)
+ .andExpect(jsonPath("$.message", containsString(expectedMessage)))
+ .andExpect(
+ result ->
+ assertThat(result.getResolvedException())
+ .isInstanceOf(MethodArgumentNotValidException.class));
+
+ verifyNoInteractions(loanApplicationService);
+ }
+
+ static Stream<Arguments> invalidLoanRequests() {
+ return Stream.of(
+ invalidLoanRequest("\"requestedAmount\": 10000,", "",
"requestedAmount: must not be null"),
+ invalidLoanRequest("10000", "null", "requestedAmount: must not be
null"),
+ invalidLoanRequest(
+ "10000", "-1", "requestedAmount: requestedAmount must be greater
than zero"),
+ invalidLoanRequest(
+ "10000", "0", "requestedAmount: requestedAmount must be greater
than zero"),
+ invalidLoanRequest("\"USD\"", "null", "currency: currency is
required"),
+ invalidLoanRequest("\"USD\"", "\"\"", "currency: currency is
required"),
+ invalidLoanRequest("USD", "US", "currency: currency must be a 3-letter
ISO 4217 code"),
+ invalidLoanRequest("USD", "usd", "currency: currency must contain only
uppercase letters"),
+ invalidLoanRequest(
+ "\"tenorMonths\": 12", "\"tenorMonths\": null", "tenorMonths:
tenorMonths is required"),
+ invalidLoanRequest(
+ "\"tenorMonths\": 12",
+ "\"tenorMonths\": 0",
+ "tenorMonths: tenorMonths must be at least 1"),
+ invalidLoanRequest(
+ "\"fullName\": \"Test Applicant\",", "", "applicant.fullName:
fullName is required"),
+ invalidLoanRequest(
+ "\"Test Applicant\"", "null", "applicant.fullName: fullName is
required"),
+ invalidLoanRequest("Test Applicant", " ", "applicant.fullName:
fullName is required"),
+ invalidLoanRequest(
+ "Test Applicant",
+ "12345",
+ "applicant.fullName: fullName must contain only letters, spaces,
hyphens, and apostrophes"),
+ invalidLoanRequest(
+ "Test Applicant",
+ "A".repeat(101),
+ "applicant.fullName: fullName must be between 2 and 100
characters"),
+ invalidLoanRequest(
+ "TEST-001",
+ "A".repeat(51),
+ "applicant.nationalId: nationalId must not exceed 50 characters"),
+ invalidLoanRequest(
+ "5000", "-1", "applicant.monthlyIncome: monthlyIncome must be zero
or positive"),
+ Arguments.of(
+ """
+ {"requestedAmount": 10000, "currency": "USD", "tenorMonths": 12}
+ """,
+ "applicant: must not be null"),
+ Arguments.of(
+ """
+ {"requestedAmount": 10000, "currency": "USD", "tenorMonths": 12,
"applicant": null}
+ """,
+ "applicant: must not be null"));
+ }
+
+ private static Arguments invalidLoanRequest(
+ final String original, final String replacement, final String
expectedMessage) {
+ return Arguments.of(VALID_REQUEST.replace(original, replacement),
expectedMessage);
+ }
+
+ @Test
+ void reportsAllInvalidFields() throws Exception {
+ expectBadRequest(APPLICATIONS, "{}")
+ .andExpect(jsonPath("$.message", containsString("requestedAmount: must
not be null")))
+ .andExpect(jsonPath("$.message", containsString("currency: currency is
required")))
+ .andExpect(jsonPath("$.message", containsString("tenorMonths:
tenorMonths is required")))
+ .andExpect(jsonPath("$.message", containsString("applicant: must not
be null")));
+
+ verifyNoInteractions(loanApplicationService);
+ }
+
+ @ParameterizedTest
+ @MethodSource("unreadableRequests")
+ void unreadableBodyReturnsBadRequest(final String body) throws Exception {
+ expectBadRequest(APPLICATIONS, body)
+ .andExpect(jsonPath("$.message").value("Request body is missing or
contains invalid JSON."))
+ .andExpect(
+ result ->
+ assertThat(result.getResolvedException())
+ .isInstanceOf(HttpMessageNotReadableException.class));
+
+ verifyNoInteractions(loanApplicationService);
+ }
+
+ static Stream<String> unreadableRequests() {
+ return Stream.of(
+ VALID_REQUEST.replace(
+ "\"fineractLoanProductId\": 1", "\"fineractLoanProductId\":
\"invalid\""),
+ VALID_REQUEST.replace(
+ "\"fineractLoanProductId\": 1", "\"fineractLoanProductId\":
9223372036854775808"),
+ VALID_REQUEST.replace("10000", "\"invalid\""),
+ VALID_REQUEST.replace("\"tenorMonths\": 12", "\"tenorMonths\":
\"invalid\""),
+ "{\"requestedAmount\":",
+ "[]",
+ "null",
+ "");
+ }
+
+ @Test
+ void customerRequestIsRejectedBeforeServiceInvocation() throws Exception {
+ expectBadRequest("/api/v1/customer/loan-applications", "{}")
+ .andExpect(jsonPath("$.message", containsString("requestedAmount: must
not be null")));
+
+ verifyNoInteractions(loanApplicationService);
+ }
+
+ @Test
+ void methodValidationIncludesNestedBodyErrors() throws Exception {
+ expectBadRequest(
+ APPLICATIONS + "/" + APPLICATION_REF + "/approval-decisions",
+ """
+ {"decision": "APPROVE", "comments": "short"}
+ """)
+ .andExpect(
+ jsonPath(
+ "$.message",
+ containsString("comments: comments must be between 10 and 2000
characters")))
+ .andExpect(
+ result ->
+ assertThat(result.getResolvedException())
+ .isInstanceOf(HandlerMethodValidationException.class));
+
+ verifyNoInteractions(approvalWorkflowService, loanApplicationService,
approvalStageRepository);
+ }
+
+ @Test
+ void methodValidationIncludesInvalidParameter() throws Exception {
+ mockMvc
+ .perform(get(APPLICATIONS + "/invalid-reference"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.status").value(400))
+ .andExpect(
+ jsonPath(
+ "$.message", containsString("applicationRef: applicationRef
must follow format")))
+ .andExpect(
+ result ->
+ assertThat(result.getResolvedException())
+ .isInstanceOf(HandlerMethodValidationException.class));
+
+ verifyNoInteractions(loanApplicationService);
+ }
+
+ @Test
+ void validRequestStillCreatesApplication() throws Exception {
+ final LoanApplication application = new LoanApplication();
+ application.setApplicationRef(APPLICATION_REF);
+ application.setStatus(LoanApplicationStatus.DRAFT);
+ application.setRequestedAmount(new BigDecimal("10000"));
+ application.setCurrency("USD");
+ when(loanApplicationService.createApplication(
+ any(CreateLoanApplicationRequest.class), eq("default")))
+ .thenReturn(application);
+
+ mockMvc
+
.perform(post(APPLICATIONS).contentType(MediaType.APPLICATION_JSON).content(VALID_REQUEST))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.applicationRef").value(APPLICATION_REF))
+ .andExpect(jsonPath("$.status").value("DRAFT"))
+ .andExpect(jsonPath("$.requestedAmount").value(10000))
+ .andExpect(jsonPath("$.currency").value("USD"));
+
+ final ArgumentCaptor<CreateLoanApplicationRequest> request =
+ ArgumentCaptor.forClass(CreateLoanApplicationRequest.class);
+ verify(loanApplicationService).createApplication(request.capture(),
eq("default"));
+
assertThat(request.getValue().getRequestedAmount()).isEqualByComparingTo("10000");
+ assertThat(request.getValue().getFineractLoanProductId()).isEqualTo(1L);
+
assertThat(request.getValue().getApplicant().getFullName()).isEqualTo("Test
Applicant");
+ }
+
+ @Test
+ void unexpectedServiceFailureRemainsInternalServerError() throws Exception {
+ when(loanApplicationService.createApplication(
+ any(CreateLoanApplicationRequest.class), eq("default")))
+ .thenThrow(new NullPointerException("Internal implementation detail"));
+
+ mockMvc
+
.perform(post(APPLICATIONS).contentType(MediaType.APPLICATION_JSON).content(VALID_REQUEST))
+ .andExpect(status().isInternalServerError())
+ .andExpect(jsonPath("$.status").value(500))
+ .andExpect(jsonPath("$.error").value("Internal Server Error"))
+ .andExpect(jsonPath("$.message").value("An unexpected error
occurred."));
+ }
+
+ @Test
+ void returnValueValidationRemainsInternalServerError() throws Exception {
Review Comment:
Good regression test Explicitly verifying that return-value validation
failures remain HTTP 500 is important. This ensures that server-side contract
violations are not incorrectly classified as client request errors.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]