diff --git a/build.gradle.kts b/build.gradle.kts index 9d00d1e..d6f5767 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ val micrometerVersion = "1.4.1" 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-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springDocOpenApiVersion") implementation("io.micrometer:micrometer-tracing-bridge-otel:$micrometerVersion") @@ -44,6 +45,7 @@ dependencies { compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") // Testing testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/gradle.lockfile b/gradle.lockfile index ab6de54..0cf1a6b 100644 --- a/gradle.lockfile +++ b/gradle.lockfile @@ -12,6 +12,7 @@ com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2=compileClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=compileClasspath com.fasterxml.jackson.module:jackson-module-parameter-names:2.18.2=compileClasspath com.fasterxml.jackson:jackson-bom:2.18.2=compileClasspath +com.fasterxml:classmate:1.7.0=compileClasspath io.micrometer:context-propagation:1.1.2=compileClasspath io.micrometer:micrometer-commons:1.14.2=compileClasspath io.micrometer:micrometer-core:1.14.2=compileClasspath @@ -48,6 +49,8 @@ org.apache.logging.log4j:log4j-to-slf4j:2.24.3=compileClasspath org.apache.tomcat.embed:tomcat-embed-core:10.1.34=compileClasspath org.apache.tomcat.embed:tomcat-embed-el:10.1.34=compileClasspath org.apache.tomcat.embed:tomcat-embed-websocket:10.1.34=compileClasspath +org.hibernate.validator:hibernate-validator:8.0.2.Final=compileClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath org.jspecify:jspecify:1.0.0=compileClasspath org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath org.projectlombok:lombok:1.18.36=compileClasspath @@ -63,6 +66,7 @@ org.springframework.boot:spring-boot-starter-actuator:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-json:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-logging:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-tomcat:3.4.1=compileClasspath +org.springframework.boot:spring-boot-starter-validation:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-web:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter:3.4.1=compileClasspath org.springframework.boot:spring-boot:3.4.1=compileClasspath diff --git a/openapi/template-payments-java-repository.openapi.yaml b/openapi/template-payments-java-repository.openapi.yaml index 7f5b1c0..0dfe001 100644 --- a/openapi/template-payments-java-repository.openapi.yaml +++ b/openapi/template-payments-java-repository.openapi.yaml @@ -29,3 +29,18 @@ paths: error: type: string example: "Internal Server Error" +components: + schemas: + ErrorDTO: + type: object + required: + - code + - message + properties: + code: + type: string + enum: + - BAD_REQUEST + - GENERIC_ERROR + message: + type: string diff --git a/src/main/java/it/gov/pagopa/template/config/json/JsonConfig.java b/src/main/java/it/gov/pagopa/template/config/json/JsonConfig.java new file mode 100644 index 0000000..6ce7370 --- /dev/null +++ b/src/main/java/it/gov/pagopa/template/config/json/JsonConfig.java @@ -0,0 +1,49 @@ +package it.gov.pagopa.template.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/template/config/json/LocalDateTimeToOffsetDateTimeSerializer.java b/src/main/java/it/gov/pagopa/template/config/json/LocalDateTimeToOffsetDateTimeSerializer.java new file mode 100644 index 0000000..c842d83 --- /dev/null +++ b/src/main/java/it/gov/pagopa/template/config/json/LocalDateTimeToOffsetDateTimeSerializer.java @@ -0,0 +1,24 @@ +package it.gov.pagopa.template.config.json; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; + +@Configuration +public class LocalDateTimeToOffsetDateTimeSerializer extends JsonSerializer { + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value != null) { + OffsetDateTime offsetDateTime = value.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + gen.writeString(offsetDateTime.toString()); + } + } +} + diff --git a/src/main/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java b/src/main/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java new file mode 100644 index 0000000..7b9bcbf --- /dev/null +++ b/src/main/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializer.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.template.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/template/exception/ControllerExceptionHandler.java b/src/main/java/it/gov/pagopa/template/exception/ControllerExceptionHandler.java new file mode 100644 index 0000000..d636f3e --- /dev/null +++ b/src/main/java/it/gov/pagopa/template/exception/ControllerExceptionHandler.java @@ -0,0 +1,96 @@ +package it.gov.pagopa.template.exception; + +import com.fasterxml.jackson.databind.JsonMappingException; +import it.gov.pagopa.template.dto.generated.ErrorDTO; +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 +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ControllerExceptionHandler { + + @ExceptionHandler({ValidationException.class, HttpMessageNotReadableException.class, MethodArgumentNotValidException.class}) + public ResponseEntity handleViolationException(Exception ex, HttpServletRequest request) { + return handleException(ex, request, HttpStatus.BAD_REQUEST, ErrorDTO.CodeEnum.BAD_REQUEST); + } + + @ExceptionHandler({ServletException.class}) + public ResponseEntity handleServletException(ServletException ex, HttpServletRequest request) { + HttpStatusCode httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + ErrorDTO.CodeEnum errorCode = ErrorDTO.CodeEnum.GENERIC_ERROR; + if (ex instanceof ErrorResponse errorResponse) { + httpStatus = errorResponse.getStatusCode(); + if (httpStatus.is4xxClientError()) { + errorCode = ErrorDTO.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, ErrorDTO.CodeEnum.GENERIC_ERROR); + } + + static ResponseEntity handleException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus, ErrorDTO.CodeEnum errorEnum) { + logException(ex, request, httpStatus); + + String message = buildReturnedMessage(ex); + + return ResponseEntity + .status(httpStatus) + .body(new ErrorDTO(errorEnum, message)); + } + + private static void logException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus) { + log.info("A {} occurred handling request {}: HttpStatus {} - {}", + ex.getClass(), + getRequestDetails(request), + httpStatus.value(), + 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) { + return "%s %s".formatted(request.getMethod(), request.getRequestURI()); + } +} diff --git a/src/test/java/it/gov/pagopa/template/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java b/src/test/java/it/gov/pagopa/template/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java new file mode 100644 index 0000000..37bf1e4 --- /dev/null +++ b/src/test/java/it/gov/pagopa/template/config/json/LocalDateTimeToOffsetDateTimeSerializerTest.java @@ -0,0 +1,51 @@ +package it.gov.pagopa.template.config.json; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.TimeZone; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class LocalDateTimeToOffsetDateTimeSerializerTest { + + @Mock + private JsonGenerator jsonGenerator; + + @Mock + private SerializerProvider serializerProvider; + + private LocalDateTimeToOffsetDateTimeSerializer dateTimeSerializer; + + @BeforeEach + public void setUp() { + dateTimeSerializer = new LocalDateTimeToOffsetDateTimeSerializer(); + } + + @Test + void testDateSerializer() throws IOException { + LocalDateTime localDateTime = LocalDateTime.of(2025, 1, 16, 9, 15, 20); + + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Rome")); + + dateTimeSerializer.serialize(localDateTime, jsonGenerator, serializerProvider); + + verify(jsonGenerator).writeString("2025-01-16T09:15:20+01:00"); + } + + @Test + void testNullDateSerializer() throws IOException { + dateTimeSerializer.serialize(null, jsonGenerator, serializerProvider); + + verifyNoInteractions(jsonGenerator); + } +} diff --git a/src/test/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java b/src/test/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java new file mode 100644 index 0000000..26885aa --- /dev/null +++ b/src/test/java/it/gov/pagopa/template/config/json/OffsetDateTimeToLocalDateTimeDeserializerTest.java @@ -0,0 +1,45 @@ +package it.gov.pagopa.template.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/template/exception/ControllerExceptionHandlerTest.java b/src/test/java/it/gov/pagopa/template/exception/ControllerExceptionHandlerTest.java new file mode 100644 index 0000000..fa19198 --- /dev/null +++ b/src/test/java/it/gov/pagopa/template/exception/ControllerExceptionHandlerTest.java @@ -0,0 +1,186 @@ +package it.gov.pagopa.template.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import it.gov.pagopa.template.config.json.JsonConfig; +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; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +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.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 = {ControllerExceptionHandlerTest.TestController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@ContextConfiguration(classes = { + ControllerExceptionHandlerTest.TestController.class, + ControllerExceptionHandler.class, + JsonConfig.class}) +class ControllerExceptionHandlerTest { + + 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 { + @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(ControllerExceptionHandlerTest.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 handleMissingServletRequestParameterException() throws Exception { + + performRequest(null, MediaType.APPLICATION_JSON) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("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("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("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("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("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("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("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("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("BAD_REQUEST")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error")); + } +}