From 60beed8e1a0b7fe8118b5d899521c279b398374a Mon Sep 17 00:00:00 2001 From: antonioT90 <34568575+antonioT90@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:19:31 +0100 Subject: [PATCH] fix: ErrorHandling and LocalDateTime json serialization (#46) --- build.gradle.kts | 1 + .../debtpositions/config/json/JsonConfig.java | 49 +++++ ...etDateTimeToLocalDateTimeDeserializer.java | 27 +++ .../DebtPositionExceptionHandler.java | 86 ++++++-- .../pu/debtpositions/model/BaseEntity.java | 4 - .../DebtPositionTypeWithCount.java | 8 +- .../pu/debtpositions/util/Utilities.java | 8 +- ...ateTimeToOffsetDateTimeSerializerTest.java | 3 +- ...teTimeToLocalDateTimeDeserializerTest.java | 45 ++++ .../DebtPositionExceptionHandlerTest.java | 192 ++++++++++++++---- .../pu/debtpositions/util/UtilitiesTest.java | 11 +- 11 files changed, 360 insertions(+), 74 deletions(-) create mode 100644 src/main/java/it/gov/pagopa/pu/debtpositions/config/json/JsonConfig.java create mode 100644 src/main/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java rename src/test/java/it/gov/pagopa/pu/debtpositions/config/{ => json}/LocalDateTimeToOffsetDateTimeSerializerTest.java (91%) create mode 100644 src/test/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java diff --git a/build.gradle.kts b/build.gradle.kts index f5b0521c..8f8a37a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,7 @@ val podamVersion = "8.0.2.RELEASE" dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-rest") diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/JsonConfig.java b/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/JsonConfig.java new file mode 100644 index 00000000..001ffcfc --- /dev/null +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/JsonConfig.java @@ -0,0 +1,49 @@ +package it.gov.pagopa.pu.debtpositions.config.json; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.util.TimeZone; + +@Configuration +public class JsonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(configureDateTimeModule()); + mapper.registerModule(new Jdk8Module()); + mapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.DEFAULT)); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + mapper.setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY); + mapper.setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY); + mapper.setVisibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + mapper.setVisibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.ANY); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setTimeZone(TimeZone.getDefault()); + return mapper; + } + + /** openApi is documenting LocalDateTime as date-time, which is interpreted as an OffsetDateTime by openApiGenerator */ + private static SimpleModule configureDateTimeModule() { + return new JavaTimeModule() + .addSerializer(LocalDateTime.class, new LocalDateTimeToOffsetDateTimeSerializer()) + .addDeserializer(LocalDateTime.class, new OffsetDateTimeToLocalDateTimeDeserializer()); + } +} diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java b/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java new file mode 100644 index 00000000..37c41722 --- /dev/null +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.pu.debtpositions.config.json; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + + +@Configuration +public class OffsetDateTimeToLocalDateTimeDeserializer extends JsonDeserializer { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + String dateString = p.getValueAsString(); + if(dateString.contains("+")){ + return OffsetDateTime.parse(dateString).toLocalDateTime(); + } else { + return LocalDateTime.parse(dateString); + } + } +} + diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandler.java b/src/main/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandler.java index 78c0c798..e2ddaaa1 100644 --- a/src/main/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandler.java +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandler.java @@ -1,20 +1,26 @@ package it.gov.pagopa.pu.debtpositions.exception; +import com.fasterxml.jackson.databind.JsonMappingException; import it.gov.pagopa.pu.debtpositions.dto.generated.DebtPositionErrorDTO; import it.gov.pagopa.pu.debtpositions.exception.custom.*; -import it.gov.pagopa.pu.debtpositions.exception.custom.ConflictErrorException; -import it.gov.pagopa.pu.debtpositions.exception.custom.InvalidValueException; -import it.gov.pagopa.pu.debtpositions.exception.custom.NotFoundException; -import it.gov.pagopa.pu.debtpositions.exception.custom.OperatorNotAuthorizedException; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ValidationException; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.stream.Collectors; + @RestControllerAdvice @Slf4j @@ -23,45 +29,93 @@ public class DebtPositionExceptionHandler { @ExceptionHandler({InvalidValueException.class}) public ResponseEntity handleInternalError(RuntimeException ex, HttpServletRequest request){ - return handleWorkflowErrorException(ex, request, HttpStatus.BAD_REQUEST, DebtPositionErrorDTO.CodeEnum.BAD_REQUEST); + return handleException(ex, request, HttpStatus.BAD_REQUEST, DebtPositionErrorDTO.CodeEnum.BAD_REQUEST); } @ExceptionHandler({OperatorNotAuthorizedException.class}) public ResponseEntity handleForbiddenError(RuntimeException ex, HttpServletRequest request){ - return handleWorkflowErrorException(ex, request, HttpStatus.FORBIDDEN, DebtPositionErrorDTO.CodeEnum.FORBIDDEN); + return handleException(ex, request, HttpStatus.FORBIDDEN, DebtPositionErrorDTO.CodeEnum.FORBIDDEN); } @ExceptionHandler({ConflictErrorException.class}) public ResponseEntity handleConflictError(RuntimeException ex, HttpServletRequest request){ - return handleWorkflowErrorException(ex, request, HttpStatus.CONFLICT, DebtPositionErrorDTO.CodeEnum.CONFLICT); + return handleException(ex, request, HttpStatus.CONFLICT, DebtPositionErrorDTO.CodeEnum.CONFLICT); } @ExceptionHandler({NotFoundException.class}) public ResponseEntity handleNotFoundError(RuntimeException ex, HttpServletRequest request){ - return handleWorkflowErrorException(ex, request, HttpStatus.NOT_FOUND, DebtPositionErrorDTO.CodeEnum.NOT_FOUND); + return handleException(ex, request, HttpStatus.NOT_FOUND, DebtPositionErrorDTO.CodeEnum.NOT_FOUND); } @ExceptionHandler({InvalidStatusTransitionException.class}) public ResponseEntity handleInvalidStatusTransitionException(RuntimeException ex, HttpServletRequest request){ - return handleWorkflowErrorException(ex, request, HttpStatus.BAD_REQUEST, DebtPositionErrorDTO.CodeEnum.BAD_REQUEST); + return handleException(ex, request, HttpStatus.BAD_REQUEST, DebtPositionErrorDTO.CodeEnum.BAD_REQUEST); + } + + + @ExceptionHandler({ValidationException.class, HttpMessageNotReadableException.class, MethodArgumentNotValidException.class}) + public ResponseEntity handleViolationException(Exception ex, HttpServletRequest request) { + return handleException(ex, request, HttpStatus.BAD_REQUEST, DebtPositionErrorDTO.CodeEnum.BAD_REQUEST); } - static ResponseEntity handleWorkflowErrorException(RuntimeException ex, HttpServletRequest request, HttpStatus httpStatus, DebtPositionErrorDTO.CodeEnum errorEnum) { - String message = logException(ex, request, httpStatus); + @ExceptionHandler({ServletException.class}) + public ResponseEntity handleServletException(ServletException ex, HttpServletRequest request) { + HttpStatusCode httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + DebtPositionErrorDTO.CodeEnum errorCode = DebtPositionErrorDTO.CodeEnum.GENERIC_ERROR; + if (ex instanceof ErrorResponse errorResponse) { + httpStatus = errorResponse.getStatusCode(); + if (httpStatus.is4xxClientError()) { + errorCode = DebtPositionErrorDTO.CodeEnum.BAD_REQUEST; + } + } + return handleException(ex, request, httpStatus, errorCode); + } + + @ExceptionHandler({RuntimeException.class}) + public ResponseEntity handleRuntimeException(RuntimeException ex, HttpServletRequest request) { + return handleException(ex, request, HttpStatus.INTERNAL_SERVER_ERROR, DebtPositionErrorDTO.CodeEnum.GENERIC_ERROR); + } + + static ResponseEntity handleException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus, DebtPositionErrorDTO.CodeEnum errorEnum) { + logException(ex, request, httpStatus); + + String message = buildReturnedMessage(ex); return ResponseEntity .status(httpStatus) - .body(DebtPositionErrorDTO.builder().code(errorEnum).message(message).build()); + .body(new DebtPositionErrorDTO(errorEnum, message)); } - private static String logException(RuntimeException ex, HttpServletRequest request, HttpStatus httpStatus) { - String message = ex.getMessage(); + private static void logException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus) { log.info("A {} occurred handling request {}: HttpStatus {} - {}", ex.getClass(), getRequestDetails(request), httpStatus.value(), - message); - return message; + ex.getMessage()); + } + + private static String buildReturnedMessage(Exception ex) { + if (ex instanceof HttpMessageNotReadableException) { + if(ex.getCause() instanceof JsonMappingException jsonMappingException){ + return "Cannot parse body: " + + jsonMappingException.getPath().stream() + .map(JsonMappingException.Reference::getFieldName) + .collect(Collectors.joining(".")) + + ": " + jsonMappingException.getOriginalMessage(); + } + return "Required request body is missing"; + } else if (ex instanceof MethodArgumentNotValidException methodArgumentNotValidException) { + return "Invalid request content:" + + methodArgumentNotValidException.getBindingResult() + .getAllErrors().stream() + .map(e -> " " + + (e instanceof FieldError fieldError? fieldError.getField(): e.getObjectName()) + + ": " + e.getDefaultMessage()) + .sorted() + .collect(Collectors.joining(";")); + } else { + return ex.getMessage(); + } } static String getRequestDetails(HttpServletRequest request) { diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/model/BaseEntity.java b/src/main/java/it/gov/pagopa/pu/debtpositions/model/BaseEntity.java index 60b8aa31..f7ebbe2d 100644 --- a/src/main/java/it/gov/pagopa/pu/debtpositions/model/BaseEntity.java +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/model/BaseEntity.java @@ -1,7 +1,5 @@ package it.gov.pagopa.pu.debtpositions.model; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import it.gov.pagopa.pu.debtpositions.config.json.LocalDateTimeToOffsetDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; @@ -26,10 +24,8 @@ public abstract class BaseEntity implements Serializable { @Column(updatable = false) @CreatedDate - @JsonSerialize(using = LocalDateTimeToOffsetDateTimeSerializer.class) private LocalDateTime creationDate; @LastModifiedDate - @JsonSerialize(using = LocalDateTimeToOffsetDateTimeSerializer.class) private LocalDateTime updateDate; @LastModifiedBy private String updateOperatorExternalId; diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/model/view/debtpositiontype/DebtPositionTypeWithCount.java b/src/main/java/it/gov/pagopa/pu/debtpositions/model/view/debtpositiontype/DebtPositionTypeWithCount.java index 1b09adc9..323370ed 100644 --- a/src/main/java/it/gov/pagopa/pu/debtpositions/model/view/debtpositiontype/DebtPositionTypeWithCount.java +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/model/view/debtpositiontype/DebtPositionTypeWithCount.java @@ -1,18 +1,17 @@ package it.gov.pagopa.pu.debtpositions.model.view.debtpositiontype; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import it.gov.pagopa.pu.debtpositions.config.json.LocalDateTimeToOffsetDateTimeSerializer; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.io.Serializable; -import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.Formula; +import java.io.Serializable; +import java.time.LocalDateTime; + @Entity @Table(name = "debt_position_type") @AllArgsConstructor @@ -25,7 +24,6 @@ public class DebtPositionTypeWithCount implements Serializable { private Long debtPositionTypeId; private String code; private String description; - @JsonSerialize(using = LocalDateTimeToOffsetDateTimeSerializer.class) private LocalDateTime updateDate; @Formula("(SELECT COUNT(*) " + "FROM debt_position_type_org org " diff --git a/src/main/java/it/gov/pagopa/pu/debtpositions/util/Utilities.java b/src/main/java/it/gov/pagopa/pu/debtpositions/util/Utilities.java index f606c8b6..11b4255b 100644 --- a/src/main/java/it/gov/pagopa/pu/debtpositions/util/Utilities.java +++ b/src/main/java/it/gov/pagopa/pu/debtpositions/util/Utilities.java @@ -1,11 +1,11 @@ package it.gov.pagopa.pu.debtpositions.util; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; public class Utilities { @@ -20,7 +20,7 @@ public static boolean isValidEmail(final String email) { public static OffsetDateTime localDatetimeToOffsetDateTime(LocalDateTime localDateTime){ return localDateTime != null - ? localDateTime.atOffset(ZoneOffset.UTC) + ? localDateTime.atOffset(ZoneId.systemDefault().getRules().getOffset(localDateTime)) : null; } public static boolean isValidIban(String iban) { diff --git a/src/test/java/it/gov/pagopa/pu/debtpositions/config/LocalDateTimeToOffsetDateTimeSerializerTest.java b/src/test/java/it/gov/pagopa/pu/debtpositions/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java similarity index 91% rename from src/test/java/it/gov/pagopa/pu/debtpositions/config/LocalDateTimeToOffsetDateTimeSerializerTest.java rename to src/test/java/it/gov/pagopa/pu/debtpositions/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java index d89e8b78..3d086c8b 100644 --- a/src/test/java/it/gov/pagopa/pu/debtpositions/config/LocalDateTimeToOffsetDateTimeSerializerTest.java +++ b/src/test/java/it/gov/pagopa/pu/debtpositions/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java @@ -1,8 +1,7 @@ -package it.gov.pagopa.pu.debtpositions.config; +package it.gov.pagopa.pu.debtpositions.config.json; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.SerializerProvider; -import it.gov.pagopa.pu.debtpositions.config.json.LocalDateTimeToOffsetDateTimeSerializer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java b/src/test/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java new file mode 100644 index 00000000..08717ca7 --- /dev/null +++ b/src/test/java/it/gov/pagopa/pu/debtpositions/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java @@ -0,0 +1,45 @@ +package it.gov.pagopa.pu.debtpositions.config.json; + +import com.fasterxml.jackson.core.JsonParser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +class OffsetDateTimeToLocalDateTimeDeserializerTest { + + private final OffsetDateTimeToLocalDateTimeDeserializer deserializer = new OffsetDateTimeToLocalDateTimeDeserializer(); + + @Test + void givenOffsetDateTimeWhenThenOk() throws IOException { + // Given + OffsetDateTime offsetDateTime = OffsetDateTime.now(); + JsonParser parser = Mockito.mock(JsonParser.class); + Mockito.when(parser.getValueAsString()) + .thenReturn(offsetDateTime.toString()); + + // When + LocalDateTime result = deserializer.deserialize(parser, null); + + // Then + Assertions.assertEquals(offsetDateTime.toLocalDateTime(), result); + } + + @Test + void givenLocalDateTimeWhenThenOk() throws IOException { + // Given + LocalDateTime localDateTime = LocalDateTime.now(); + JsonParser parser = Mockito.mock(JsonParser.class); + Mockito.when(parser.getValueAsString()) + .thenReturn(localDateTime.toString()); + + // When + LocalDateTime result = deserializer.deserialize(parser, null); + + // Then + Assertions.assertEquals(localDateTime, result); + } +} diff --git a/src/test/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandlerTest.java b/src/test/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandlerTest.java index 13f2aa77..a1251fec 100644 --- a/src/test/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandlerTest.java +++ b/src/test/java/it/gov/pagopa/pu/debtpositions/exception/DebtPositionExceptionHandlerTest.java @@ -1,10 +1,16 @@ package it.gov.pagopa.pu.debtpositions.exception; -import it.gov.pagopa.pu.debtpositions.exception.custom.ConflictErrorException; -import it.gov.pagopa.pu.debtpositions.exception.custom.InvalidValueException; -import it.gov.pagopa.pu.debtpositions.exception.custom.NotFoundException; -import it.gov.pagopa.pu.debtpositions.exception.custom.OperatorNotAuthorizedException; +import com.fasterxml.jackson.databind.ObjectMapper; +import it.gov.pagopa.pu.debtpositions.config.json.JsonConfig; import it.gov.pagopa.pu.debtpositions.exception.custom.*; +import jakarta.servlet.ServletException; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,46 +22,88 @@ import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; -import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import java.time.LocalDateTime; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; @ExtendWith({SpringExtension.class}) @WebMvcTest(value = {DebtPositionExceptionHandlerTest.TestController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) @ContextConfiguration(classes = { DebtPositionExceptionHandlerTest.TestController.class, - DebtPositionExceptionHandler.class}) -public class DebtPositionExceptionHandlerTest { + DebtPositionExceptionHandler.class, + JsonConfig.class}) +class DebtPositionExceptionHandlerTest { public static final String DATA = "data"; + public static final TestRequestBody BODY = new TestRequestBody("bodyData", null, "abc", LocalDateTime.now()); + @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; @MockitoSpyBean private TestController testControllerSpy; + @MockitoSpyBean + private RequestMappingHandlerAdapter requestMappingHandlerAdapterSpy; @RestController @Slf4j static class TestController { - - @GetMapping("/test") - String testEndpoint(@RequestParam(DATA) String data) { + @PostMapping(value = "/test", produces = MediaType.APPLICATION_JSON_VALUE) + String testEndpoint(@RequestParam(DATA) String data, @Valid @RequestBody TestRequestBody body) { return "OK"; } } + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TestRequestBody { + @NotNull + private String requiredField; + private String notRequiredField; + @Pattern(regexp = "[a-z]+") + private String lowerCaseAlphabeticField; + private LocalDateTime dateTimeField; + } + + private ResultActions performRequest(String data, MediaType accept) throws Exception { + return performRequest(data, accept, objectMapper.writeValueAsString(DebtPositionExceptionHandlerTest.BODY)); + } + + private ResultActions performRequest(String data, MediaType accept, String body) throws Exception { + MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/test") + .param(DATA, data) + .accept(accept); + + if (body != null) { + requestBuilder + .contentType(MediaType.APPLICATION_JSON) + .content(body); + } + + return mockMvc.perform(requestBuilder); + } + @Test void handleInvalidValueExceptionError() throws Exception { - doThrow(new InvalidValueException("Error")).when(testControllerSpy).testEndpoint(DATA); + doThrow(new InvalidValueException("Error")).when(testControllerSpy).testEndpoint(DATA,BODY); - mockMvc.perform(MockMvcRequestBuilders.get("/test") - .param(DATA, DATA) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON)) + performRequest(DATA, MediaType.APPLICATION_JSON) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); @@ -64,12 +112,9 @@ void handleInvalidValueExceptionError() throws Exception { @Test void handleForbiddenErrorExceptionError() throws Exception { - doThrow(new OperatorNotAuthorizedException("Error")).when(testControllerSpy).testEndpoint(DATA); + doThrow(new OperatorNotAuthorizedException("Error")).when(testControllerSpy).testEndpoint(DATA, BODY); - mockMvc.perform(MockMvcRequestBuilders.get("/test") - .param(DATA, DATA) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON)) + performRequest(DATA, MediaType.APPLICATION_JSON) .andExpect(MockMvcResultMatchers.status().isForbidden()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_FORBIDDEN")) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); @@ -78,12 +123,9 @@ void handleForbiddenErrorExceptionError() throws Exception { @Test void handleGenericErrorExceptionError() throws Exception { - doThrow(new ConflictErrorException("Error")).when(testControllerSpy).testEndpoint(DATA); + doThrow(new ConflictErrorException("Error")).when(testControllerSpy).testEndpoint(DATA, BODY); - mockMvc.perform(MockMvcRequestBuilders.get("/test") - .param(DATA, DATA) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON)) + performRequest(DATA, MediaType.APPLICATION_JSON) .andExpect(MockMvcResultMatchers.status().isConflict()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_CONFLICT")) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); @@ -92,12 +134,9 @@ void handleGenericErrorExceptionError() throws Exception { @Test void handleNotFoundException() throws Exception { - doThrow(new NotFoundException("Error")).when(testControllerSpy).testEndpoint(DATA); + doThrow(new NotFoundException("Error")).when(testControllerSpy).testEndpoint(DATA, BODY); - mockMvc.perform(MockMvcRequestBuilders.get("/test") - .param(DATA, DATA) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON)) + performRequest(DATA, MediaType.APPLICATION_JSON) .andExpect(MockMvcResultMatchers.status().isNotFound()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_NOT_FOUND")) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); @@ -106,12 +145,95 @@ void handleNotFoundException() throws Exception { @Test void handleInvalidStatusTransitionExceptionError() throws Exception { - doThrow(new InvalidStatusTransitionException("Error")).when(testControllerSpy).testEndpoint(DATA); + doThrow(new InvalidStatusTransitionException("Error")).when(testControllerSpy).testEndpoint(DATA, BODY); - mockMvc.perform(MockMvcRequestBuilders.get("/test") - .param(DATA, DATA) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON)) + performRequest(DATA, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); + } + + @Test + void handleMissingServletRequestParameterException() throws Exception { + + performRequest(null, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Required request parameter 'data' for method parameter type String is not present")); + + } + + @Test + void handleRuntimeExceptionError() throws Exception { + doThrow(new RuntimeException("Error")).when(testControllerSpy).testEndpoint(DATA, BODY); + + performRequest(DATA, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isInternalServerError()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_GENERIC_ERROR")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); + } + + @Test + void handleGenericServletException() throws Exception { + doThrow(new ServletException("Error")) + .when(requestMappingHandlerAdapterSpy).handle(any(), any(), any()); + + performRequest(DATA, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isInternalServerError()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_GENERIC_ERROR")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); + } + + @Test + void handle4xxHttpServletException() throws Exception { + performRequest(DATA, MediaType.parseMediaType("application/hal+json")) + .andExpect(MockMvcResultMatchers.status().isNotAcceptable()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("No acceptable representation")); + } + + @Test + void handleNoBodyException() throws Exception { + performRequest(DATA, MediaType.APPLICATION_JSON, null) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Required request body is missing")); + } + + @Test + void handleInvalidBodyException() throws Exception { + performRequest(DATA, MediaType.APPLICATION_JSON, + "{\"notRequiredField\":\"notRequired\",\"lowerCaseAlphabeticField\":\"ABC\"}") + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Invalid request content: lowerCaseAlphabeticField: must match \"[a-z]+\"; requiredField: must not be null")); + } + + @Test + void handleNotParsableBodyException() throws Exception { + performRequest(DATA, MediaType.APPLICATION_JSON, + "{\"notRequiredField\":\"notRequired\",\"dateTimeField\":\"2025-02-05\"}") + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Cannot parse body: dateTimeField: Text '2025-02-05' could not be parsed at index 10")); + } + + @Test + void handle5xxHttpServletException() throws Exception { + doThrow(new ServerErrorException("Error", new RuntimeException("Error"))) + .when(requestMappingHandlerAdapterSpy).handle(any(), any(), any()); + + performRequest(DATA, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isInternalServerError()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_GENERIC_ERROR")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("500 INTERNAL_SERVER_ERROR \"Error\"")); + } + + @Test + void handleViolationException() throws Exception { + doThrow(new ConstraintViolationException("Error", Set.of())).when(testControllerSpy).testEndpoint(DATA, BODY); + + performRequest(DATA, MediaType.APPLICATION_JSON) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("DEBT_POSITION_BAD_REQUEST")) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); diff --git a/src/test/java/it/gov/pagopa/pu/debtpositions/util/UtilitiesTest.java b/src/test/java/it/gov/pagopa/pu/debtpositions/util/UtilitiesTest.java index a71945b6..66acacc1 100644 --- a/src/test/java/it/gov/pagopa/pu/debtpositions/util/UtilitiesTest.java +++ b/src/test/java/it/gov/pagopa/pu/debtpositions/util/UtilitiesTest.java @@ -4,9 +4,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import java.time.LocalDateTime; import java.time.OffsetDateTime; -import java.time.ZoneOffset; import static org.junit.jupiter.api.Assertions.*; @@ -21,10 +19,9 @@ void testIbanInvalid(){ @Test void testLocalDatetimeToOffsetDateTime() { - LocalDateTime localDateTime = LocalDateTime.of(2025, 1, 1, 0, 0); - OffsetDateTime expectedOffsetDateTime = localDateTime.atOffset(ZoneOffset.UTC); + OffsetDateTime expectedOffsetDateTime = OffsetDateTime.now(); - OffsetDateTime result = Utilities.localDatetimeToOffsetDateTime(localDateTime); + OffsetDateTime result = Utilities.localDatetimeToOffsetDateTime(expectedOffsetDateTime.toLocalDateTime()); assertEquals(expectedOffsetDateTime, result); } @@ -38,9 +35,7 @@ void testValidateEmptyPIVA(String piva){ @Test void testLocalDatetimeToOffsetDateTimeWithNull() { - OffsetDateTime result = Utilities.localDatetimeToOffsetDateTime(null); - - assertNull(result, "The result should be null for a null input."); + assertNull(Utilities.localDatetimeToOffsetDateTime(null), "The result should be null for a null input."); } @Test