diff --git a/src/main/java/de/ozgcloud/admin/common/errorhandling/DynamicViolationParameter.java b/src/main/java/de/ozgcloud/admin/common/errorhandling/DynamicViolationParameter.java new file mode 100644 index 0000000000000000000000000000000000000000..7efd10a1a615dfe4715ebaf608ee6655068b1673 --- /dev/null +++ b/src/main/java/de/ozgcloud/admin/common/errorhandling/DynamicViolationParameter.java @@ -0,0 +1,15 @@ +package de.ozgcloud.admin.common.errorhandling; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class DynamicViolationParameter { + + @Builder.Default + private Map<String, Object> map = new HashMap<>(); +} \ No newline at end of file diff --git a/src/main/java/de/ozgcloud/admin/common/errorhandling/ExceptionController.java b/src/main/java/de/ozgcloud/admin/common/errorhandling/ExceptionController.java index 51fc68105ef225ee93d66f36a448ca4b54c5a9b0..9f1e8cd10e9321012885820ed9dea5d7ea4ab1a5 100644 --- a/src/main/java/de/ozgcloud/admin/common/errorhandling/ExceptionController.java +++ b/src/main/java/de/ozgcloud/admin/common/errorhandling/ExceptionController.java @@ -21,13 +21,7 @@ */ package de.ozgcloud.admin.common.errorhandling; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import jakarta.validation.ConstraintViolationException; import org.springframework.data.rest.webmvc.ResourceNotFoundException; import org.springframework.http.HttpStatus; @@ -39,12 +33,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import de.ozgcloud.common.errorhandling.TechnicalException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; @RestControllerAdvice +@RequiredArgsConstructor public class ExceptionController extends ResponseEntityExceptionHandler { + private final ProblemDetailMapper problemDetailMapper; + @ExceptionHandler(RuntimeException.class) @ResponseBody public ProblemDetail handleRuntimeException(RuntimeException ex) { @@ -80,28 +76,7 @@ public class ExceptionController extends ResponseEntityExceptionHandler { } @ExceptionHandler(ConstraintViolationException.class) - @ResponseBody - public ProblemDetail handleConstraintViolationException(ConstraintViolationException ex) { - return buildConstraintViolationProblemDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex); - } - - private ProblemDetail buildConstraintViolationProblemDetail(HttpStatus status, ConstraintViolationException ex) { - var problemDetail = ProblemDetail.forStatusAndDetail(status, ex.getLocalizedMessage()); - problemDetail.setProperty("invalid-params", getDetailedviolationList(ex.getConstraintViolations())); - return problemDetail; - } - - private List<Map<String, String>> getDetailedviolationList(Set<ConstraintViolation<?>> violations) { - var detailedViolations = new ArrayList<Map<String, String>>(); - Optional.ofNullable(violations).orElse(Collections.emptySet()).forEach(v -> detailedViolations.add(buildDetailedViolation(v))); - return detailedViolations; - - } - - private Map<String, String> buildDetailedViolation(ConstraintViolation<?> violation) { - var detailedViolation = new LinkedHashMap<String, String>(); - detailedViolation.put("name", violation.getPropertyPath().toString()); - detailedViolation.put("reason", violation.getMessage()); - return detailedViolation; + public ProblemDetail handleConstraintViolationException(ConstraintViolationException e) { + return problemDetailMapper.fromConstraintViolationException(e); } } diff --git a/src/main/java/de/ozgcloud/admin/common/errorhandling/IssueParam.java b/src/main/java/de/ozgcloud/admin/common/errorhandling/IssueParam.java new file mode 100644 index 0000000000000000000000000000000000000000..99e36a23d4612dbca69f8e765476357f730928ba --- /dev/null +++ b/src/main/java/de/ozgcloud/admin/common/errorhandling/IssueParam.java @@ -0,0 +1,11 @@ +package de.ozgcloud.admin.common.errorhandling; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +class IssueParam { + private String name; + private String value; +} \ No newline at end of file diff --git a/src/main/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapper.java b/src/main/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..0884d2fa1f807ab74f28db3e8aaea82585abf6c7 --- /dev/null +++ b/src/main/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapper.java @@ -0,0 +1,89 @@ +package de.ozgcloud.admin.common.errorhandling; + +import java.util.Collections; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.metadata.ConstraintDescriptor; + +import org.hibernate.validator.engine.HibernateConstraintViolation; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.stereotype.Component; + +import de.ozgcloud.common.errorhandling.ExceptionUtil; + +@Component +public class ProblemDetailMapper { + + static final String INVALID_PARAMS_KEY_CONSTRAINT_PARAMETERS = "constraintParameters"; + static final String INVALID_PARAMS_KEY_REASON = "reason"; + static final String INVALID_PARAMS_KEY_VALUE = "value"; + static final String INVALID_PARAMS_KEY_NAME = "name"; + static final String INVALID_PARAMS = "invalidParams"; + static final String PROVIDED_VALUE_WAS_NULL = "Provided value was null!"; + private static final Set<String> IGNORABLE_CONSTRAINT_VIOLATION_ATTRIBUTES = Set.of("groups", "payload", "message"); + + public ProblemDetail fromConstraintViolationException(ConstraintViolationException e) { + var problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, buildMessageWithExceptionId(e)); + problemDetail.setProperty(INVALID_PARAMS, buildInvalidParams(e.getConstraintViolations()).toList()); + return problemDetail; + } + + String buildMessageWithExceptionId(ConstraintViolationException e) { + var exceptionId = createExceptionId(); + return ExceptionUtil.formatMessageWithExceptionId(e.getLocalizedMessage(), + exceptionId); + } + + String createExceptionId() { + return UUID.randomUUID().toString(); + } + + Stream<Map<String, Object>> buildInvalidParams(Set<ConstraintViolation<?>> violations) { + return Objects.requireNonNullElse(violations, Collections.<ConstraintViolation<?>>emptySet()).stream().map(this::buildDetailedViolation); + } + + Map<String, Object> buildDetailedViolation(ConstraintViolation<?> violation) { + return Map.of( + INVALID_PARAMS_KEY_NAME, violation.getPropertyPath().toString(), + INVALID_PARAMS_KEY_VALUE, Objects.requireNonNullElse(violation.getInvalidValue(), PROVIDED_VALUE_WAS_NULL).toString(), + INVALID_PARAMS_KEY_REASON, violation.getMessage(), + INVALID_PARAMS_KEY_CONSTRAINT_PARAMETERS, buildParameters(violation).toList()); + } + + Stream<IssueParam> buildParameters(ConstraintViolation<?> violation) { + var dynamicPayload = getDynamicPayload(violation); + return Optional.ofNullable(violation.getConstraintDescriptor()) + .map(ConstraintDescriptor::getAttributes) + .map(descr -> descr.entrySet().stream() + .filter(entry -> !IGNORABLE_CONSTRAINT_VIOLATION_ATTRIBUTES.contains(entry.getKey())) + .map(entryMap -> buildIssueParam(entryMap, dynamicPayload))) + .orElse(Stream.empty()); + } + + private IssueParam buildIssueParam(Entry<String, Object> entry, Optional<DynamicViolationParameter> dynamicValues) { + return IssueParam.builder().name(entry.getKey()).value(getValue(entry, dynamicValues)).build(); + } + + private String getValue(Entry<String, Object> entry, Optional<DynamicViolationParameter> dynamicValues) { + return dynamicValues + .map(DynamicViolationParameter::getMap) + .map(map -> map.get(entry.getKey())) + .filter(Objects::nonNull) + .map(String::valueOf) + .orElse(String.valueOf(entry.getValue())); + } + + private Optional<DynamicViolationParameter> getDynamicPayload(ConstraintViolation<?> violation) { + HibernateConstraintViolation<?> hibernateViolation = violation.unwrap(HibernateConstraintViolation.class); + return Optional.ofNullable(hibernateViolation.getDynamicPayload(DynamicViolationParameter.class)); + } +} diff --git a/src/test/java/de/ozgcloud/admin/common/errorhandling/ConstraintViolationTestFactory.java b/src/test/java/de/ozgcloud/admin/common/errorhandling/ConstraintViolationTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..535233c78eac3b68cd08256a62214e6cccb60b8c --- /dev/null +++ b/src/test/java/de/ozgcloud/admin/common/errorhandling/ConstraintViolationTestFactory.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den + * Ministerpräsidenten des Landes Schleswig-Holstein + * Staatskanzlei + * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ +package de.ozgcloud.admin.common.errorhandling; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.UUID; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Path; +import jakarta.validation.metadata.ConstraintDescriptor; + +import org.hibernate.validator.engine.HibernateConstraintViolation; + +import com.thedeanda.lorem.LoremIpsum; + +public class ConstraintViolationTestFactory { + + static final String EXCEPTION_ID = UUID.randomUUID().toString(); + + static final String PARAM_NAME = "param"; + static final String PARAM_VALUE = "3"; + static final String PARAM_DYNAMIC_VALUE = "20"; + static final String MESSAGE = LoremIpsum.getInstance().getWords(5); + static final String MESSAGE_CODE = "message.code"; + private static final String PATH_PREFIX = "createCommandByRelation"; + static final String PATH_FIELD = "command.wiedervorlage.betreff"; + public static final String PATH = PATH_PREFIX + "." + PATH_FIELD; + + public static ConstraintViolation<?> buildMockedConstraintViolation() { + return buildMockedConstraintViolationWithDynamicPayload(null); + } + + @SuppressWarnings({ "unchecked" }) + public static <T> ConstraintViolation<T> buildMockedConstraintViolationWithDynamicPayload(DynamicViolationParameter dynamicViolationParameter) { + var violation = mock(ConstraintViolation.class); + var hibernateViolation = mock(HibernateConstraintViolation.class); + var constraintDescriptor = mock(ConstraintDescriptor.class); + + var path = mock(Path.class); + when(path.toString()).thenReturn(PATH); + when(violation.getPropertyPath()).thenReturn(path); + when(violation.getMessage()).thenReturn(MESSAGE); + when(violation.getConstraintDescriptor()).thenReturn(constraintDescriptor); + when(violation.getInvalidValue()).thenReturn(PARAM_VALUE); + when(constraintDescriptor.getAttributes()).thenReturn(Map.of(PARAM_NAME, PARAM_DYNAMIC_VALUE)); + when(violation.unwrap(any())).thenReturn(hibernateViolation); + when(hibernateViolation.getDynamicPayload(any())).thenReturn(dynamicViolationParameter); + + return violation; + } +} \ No newline at end of file diff --git a/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerITCase.java b/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerITCase.java index e965505fc19fda822757ed5b189c88fb9a1d7484..50ed4eb4df1bf5248379b59691cae4abfdfa5d6e 100644 --- a/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerITCase.java +++ b/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerITCase.java @@ -15,6 +15,7 @@ import jakarta.validation.constraints.NotEmpty; import org.apache.commons.lang3.StringUtils; import org.assertj.core.util.Arrays; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -45,54 +46,55 @@ class ExceptionControllerITCase { @Nested class TestConstraintViolationException { - @Test - @SneakyThrows - void shouldHaveInvalidFieldNameInResponse() { + @BeforeEach + void setUpException() { when(modelAssembler.toModel(any())).thenAnswer((a) -> { - throw new ConstraintViolationException(getConstraintViolations("Not Empty")); + throw new ConstraintViolationException(getConstraintViolations()); }); + } + private Set<ConstraintViolation<ValidatedClass>> getConstraintViolations() { + Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + return validator.validate(ValidatedClass.builder().string(StringUtils.EMPTY).build()); + } + + @Test + @SneakyThrows + void shouldHaveInvalidFieldNameInResponse() { var result = performGet(); - result.andExpect(jsonPath("$.invalid-params[*].name").value("string2")); + result.andExpect(jsonPath("$.invalidParams[*].name").value("string")); } @Test @SneakyThrows - void shouldHaveTwoInvalidParamsInResponse() { - when(modelAssembler.toModel(any())).thenAnswer((a) -> { - throw new ConstraintViolationException(getConstraintViolations(StringUtils.EMPTY)); - }); - + void shouldHaveInvalidParamsInResponse() { var result = performGet(); - result.andExpect(jsonPath("$.invalid-params[*].length()").value(Arrays.asList(new Integer[] { 2, 2 }))); + result.andExpect(jsonPath("$.invalidParams[*].length()").value(Arrays.asList(new Integer[] { 4 }))); } @Test @SneakyThrows void shouldHaveMessageInResponse() { - when(modelAssembler.toModel(any())).thenAnswer((a) -> { - throw new ConstraintViolationException(getConstraintViolations(StringUtils.EMPTY)); - }); - var result = performGet(); - result.andExpect(jsonPath("$.invalid-params[0].reason").value("Empty field")); + result.andExpect(jsonPath("$.invalidParams[0].reason").value("Empty field")); } - private Set<ConstraintViolation<ValidatedClass>> getConstraintViolations(String string) { - Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); - return validator.validate(ValidatedClass.builder().string1(string).build()); + @Test + @SneakyThrows + void shouldHaveValueInResponse() { + var result = performGet(); + + result.andExpect(jsonPath("$.invalidParams[0].value").value("")); } @Getter @Builder private static class ValidatedClass { @NotEmpty(message = "Empty field") - private String string1; - @NotEmpty(message = "Empty field") - private String string2; + private String string; } @SneakyThrows diff --git a/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerTest.java b/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerTest.java index 367ed8afd534aa2c443453447e060011b6c1bd22..d263ff1a5c34b344e8528f768a019f29d27e0a9e 100644 --- a/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerTest.java +++ b/src/test/java/de/ozgcloud/admin/common/errorhandling/ExceptionControllerTest.java @@ -21,16 +21,25 @@ */ package de.ozgcloud.admin.common.errorhandling; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.*; +import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.Collections; + +import jakarta.validation.ConstraintViolationException; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.springframework.data.rest.webmvc.ResourceNotFoundException; import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; import org.springframework.security.access.AccessDeniedException; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -40,19 +49,26 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.jayway.jsonpath.JsonPath; +import com.thedeanda.lorem.LoremIpsum; import de.ozgcloud.common.errorhandling.TechnicalException; import lombok.SneakyThrows; class ExceptionControllerTest { + @InjectMocks + private ExceptionController exceptionController; + + @Mock + private ProblemDetailMapper problemDetailMapper; + private MockMvc mockMvc; @BeforeEach void setup() { mockMvc = MockMvcBuilders .standaloneSetup(new TestErrorController()) - .setControllerAdvice(new ExceptionController()).build(); + .setControllerAdvice(exceptionController).build(); } @DisplayName("Runtime Exception") @@ -406,6 +422,36 @@ class ExceptionControllerTest { } } + @Nested + class TestHandleConstraintViolationException { + private final String exceptionMessage = LoremIpsum.getInstance().getWords(5); + + private final ConstraintViolationException exception = new ConstraintViolationException(exceptionMessage, + Collections.singleton(ConstraintViolationTestFactory.buildMockedConstraintViolation())); + + @Test + void shouldBuildConstraintViolationProblemDetail() { + handleException(); + + verify(problemDetailMapper).fromConstraintViolationException(exception); + } + + @Test + void shouldReturnBuiltProblemDetail() { + var expectedProblemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, + exceptionMessage); + when(problemDetailMapper.fromConstraintViolationException(exception)).thenReturn(expectedProblemDetail); + + var problemDetail = handleException(); + + assertThat(problemDetail).isEqualTo(expectedProblemDetail); + } + + private ProblemDetail handleException() { + return exceptionController.handleConstraintViolationException(exception); + } + } + @SneakyThrows private String getDetailFromResponseContent(ResultActions resultActions) { return JsonPath.read(resultActions.andReturn().getResponse().getContentAsString(), "$.detail"); @@ -443,4 +489,5 @@ class TestErrorController { String throwTechnicalExceptionException() throws Exception { throw new TechnicalException(ERROR_MESSAGE); } + } \ No newline at end of file diff --git a/src/test/java/de/ozgcloud/admin/common/errorhandling/IssueParamTestFactory.java b/src/test/java/de/ozgcloud/admin/common/errorhandling/IssueParamTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..42a063990bb25f5c993f087179e7e00294d486fd --- /dev/null +++ b/src/test/java/de/ozgcloud/admin/common/errorhandling/IssueParamTestFactory.java @@ -0,0 +1,17 @@ +package de.ozgcloud.admin.common.errorhandling; + +import de.ozgcloud.admin.common.errorhandling.IssueParam.IssueParamBuilder; + +public class IssueParamTestFactory { + public static final String PARAM_NAME = ConstraintViolationTestFactory.PARAM_NAME; + public static final String PARAM_DYNAMIC_VALUE = ConstraintViolationTestFactory.PARAM_DYNAMIC_VALUE; + + public static IssueParam create() { + return createBuilder() + .build(); + } + + public static IssueParamBuilder createBuilder() { + return IssueParam.builder().name(PARAM_NAME).value(PARAM_DYNAMIC_VALUE); + } +} diff --git a/src/test/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapperTest.java b/src/test/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..da2d73946bcafb2bcf1223096ac3208cf5a927ce --- /dev/null +++ b/src/test/java/de/ozgcloud/admin/common/errorhandling/ProblemDetailMapperTest.java @@ -0,0 +1,312 @@ +package de.ozgcloud.admin.common.errorhandling; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.AbstractMap.SimpleEntry; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.metadata.ConstraintDescriptor; + +import org.hibernate.validator.engine.HibernateConstraintViolation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.common.errorhandling.ExceptionUtil; + +class ProblemDetailMapperTest { + + @Spy + private ProblemDetailMapper mapper; + + @Nested + class TestFromConstraintViolationException { + private final String exceptionMessage = LoremIpsum.getInstance().getWords(5); + private final Set<ConstraintViolation<?>> violations = Collections.singleton(ConstraintViolationTestFactory.buildMockedConstraintViolation()); + private final ConstraintViolationException exception = new ConstraintViolationException(exceptionMessage, violations); + private final String message = LoremIpsum.getInstance().getWords(5); + + @BeforeEach + void mockMapper() { + doReturn(message).when(mapper).buildMessageWithExceptionId(exception); + } + + @Test + void shouldGetMessageWithExcpetionId() { + callMapper(); + + verify(mapper).buildMessageWithExceptionId(exception); + } + + @Test + void shouldHaveStatusUnprocessableEntity() { + var problemDetail = callMapper(); + + assertThat(problemDetail.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.value()); + } + + @Test + void shouldHaveMessageInDetail() { + var problemDetail = callMapper(); + + assertThat(problemDetail.getDetail()).contains(message); + } + + @Test + void shouldGetDetailedViolationList() { + callMapper(); + + verify(mapper).buildInvalidParams(violations); + } + + @Test + void shouldSetPropertyInvalidParams() { + var expectedInvalidParamsValue = Map.of(); + doReturn(Stream.of(expectedInvalidParamsValue)).when(mapper).buildInvalidParams(violations); + + var problemDetail = callMapper(); + + assertThat(problemDetail.getProperties()) + .containsExactly(new SimpleEntry<String, Object>(ProblemDetailMapper.INVALID_PARAMS, List.of(expectedInvalidParamsValue))); + } + + private ProblemDetail callMapper() { + return mapper.fromConstraintViolationException(exception); + } + } + + @Nested + class TestBuildMessageWithExceptionId { + + private final String exceptionMessage = LoremIpsum.getInstance().getWords(5); + private final String exceptionId = UUID.randomUUID().toString(); + private final ConstraintViolationException exception = new ConstraintViolationException(exceptionMessage, null); + + @BeforeEach + void mockCreateExcpetionId() { + doReturn(exceptionId).when(mapper).createExceptionId(); + } + + @Test + void shouldCreateExceptionId() { + callMapper(); + + verify(mapper).createExceptionId(); + } + + @Test + void shouldFormatMessageWithExceptionId() { + var messageWithId = callMapper(); + + assertThat(messageWithId).isEqualTo(ExceptionUtil.formatMessageWithExceptionId(exceptionMessage, exceptionId)); + } + + private String callMapper() { + return mapper.buildMessageWithExceptionId(exception); + } + } + + @Nested + class TestBuildInvalidParams { + + @Nested + class OnViolations { + private final Set<ConstraintViolation<?>> violations = Set.of(ConstraintViolationTestFactory.buildMockedConstraintViolation(), + ConstraintViolationTestFactory.buildMockedConstraintViolation()); + + @Test + void shouldCallBuildDetailedViolation() { + callMapper().toList(); + + violations.forEach(violation -> verify(mapper).buildDetailedViolation(violation)); + } + + @Test + void shouldReturnListWithDetailedViolations() { + Map<String, Object> detailsMap = Map.of(LoremIpsum.getInstance().getWords(1), LoremIpsum.getInstance().getWords(1)); + violations.forEach(violation -> doReturn(detailsMap).when(mapper).buildDetailedViolation(violation)); + + var detailedViolations = callMapper(); + + assertThat(detailedViolations).containsExactly(detailsMap, detailsMap); + } + + private Stream<Map<String, Object>> callMapper() { + return mapper.buildInvalidParams(violations); + } + } + + @Nested + class OnEmptyViolations { + private final Set<ConstraintViolation<?>> violations = Collections.emptySet(); + + @Test + void shouldCallNotBuildDetailedViolation() { + callMapper(); + + verify(mapper, never()).buildDetailedViolation(any()); + } + + @Test + void shouldReturnListWithDetailedViolations() { + var detailedViolations = callMapper(); + + assertThat(detailedViolations).isEmpty(); + } + + private Stream<Map<String, Object>> callMapper() { + return mapper.buildInvalidParams(violations); + } + } + } + + @Nested + class TestBuildDetailedViolation { + private final ConstraintViolation<?> violation = ConstraintViolationTestFactory.buildMockedConstraintViolation(); + + @Test + void shouldContainFieldName() { + var expectedEntry = new SimpleEntry<>(ProblemDetailMapper.INVALID_PARAMS_KEY_NAME, ConstraintViolationTestFactory.PATH); + + var detailedViolation = callMapper(); + + assertThat(detailedViolation).contains(expectedEntry); + } + + @Test + void shouldContainValue() { + var expectedEntry = new SimpleEntry<>(ProblemDetailMapper.INVALID_PARAMS_KEY_VALUE, ConstraintViolationTestFactory.PARAM_VALUE); + + var detailedViolation = callMapper(); + + assertThat(detailedViolation).contains(expectedEntry); + } + + @Test + void shouldHandleNullValue() { + when(violation.getInvalidValue()).thenReturn(null); + var expectedEntry = new SimpleEntry<>(ProblemDetailMapper.INVALID_PARAMS_KEY_VALUE, + ProblemDetailMapper.PROVIDED_VALUE_WAS_NULL); + + var detailedViolation = callMapper(); + + assertThat(detailedViolation).contains(expectedEntry); + } + + @Test + void shouldContainReason() { + var expectedEntry = new SimpleEntry<>(ProblemDetailMapper.INVALID_PARAMS_KEY_REASON, ConstraintViolationTestFactory.MESSAGE); + + var detailedViolation = callMapper(); + + assertThat(detailedViolation).contains(expectedEntry); + } + + @Test + void shouldBuildParameters() { + callMapper(); + + verify(mapper).buildParameters(violation); + } + + @Test + void shouldContainConstraintParameters() { + var issueParameter = IssueParamTestFactory.create(); + doReturn(Stream.of(issueParameter)).when(mapper).buildParameters(violation); + var expectedEntry = new SimpleEntry<String, Object>(ProblemDetailMapper.INVALID_PARAMS_KEY_CONSTRAINT_PARAMETERS, + List.of(issueParameter)); + + var detailedViolation = callMapper(); + + assertThat(detailedViolation).contains(expectedEntry); + } + + private Map<String, Object> callMapper() { + return mapper.buildDetailedViolation(violation); + } + } + + @Nested + class TestBuildParameters { + + @Mock + private ConstraintViolation<?> violation; + + @Mock + @SuppressWarnings("rawtypes") + private ConstraintDescriptor constraintDescriptor; + + @Mock + @SuppressWarnings("rawtypes") + private HibernateConstraintViolation hibernateConstraintViolation; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUpViolationMocks() { + when(violation.getConstraintDescriptor()).thenReturn(constraintDescriptor); + when(violation.unwrap(HibernateConstraintViolation.class)).thenReturn(hibernateConstraintViolation); + when(constraintDescriptor.getAttributes()) + .thenReturn(Map.of(ConstraintViolationTestFactory.PARAM_NAME, ConstraintViolationTestFactory.PARAM_DYNAMIC_VALUE)); + } + + @Nested + class OnNonDynamicPayload { + + @Test + void shouldBuildIssueParam() { + var issueParams = mapper.buildParameters(violation); + + assertThat(issueParams).usingRecursiveFieldByFieldElementComparator().contains(IssueParamTestFactory.create()); + } + + } + + @Nested + class TestWithDynamicPayload { + + @SuppressWarnings("unchecked") + @Test + void shouldHaveReplacedIssueParameterName() { + var dynamicValue = LoremIpsum.getInstance().getWords(1); + var dynamicViolationParameter = DynamicViolationParameter.builder() + .map(Map.of(ConstraintViolationTestFactory.PARAM_NAME, dynamicValue)) + .build(); + when(hibernateConstraintViolation.getDynamicPayload(DynamicViolationParameter.class)).thenReturn(dynamicViolationParameter); + var expectedIssueParam = IssueParamTestFactory.createBuilder().value(dynamicValue).build(); + + var issueParams = mapper.buildParameters(violation); + + assertThat(issueParams).usingRecursiveFieldByFieldElementComparator().contains(expectedIssueParam); + } + + @SuppressWarnings("unchecked") + @Test + void shouldIgnoreKeyNotPresentInEntry() { + var dynamicViolationParameter = DynamicViolationParameter.builder() + .map(Map.of(LoremIpsum.getInstance().getWords(1), LoremIpsum.getInstance().getWords(1))) + .build(); + when(hibernateConstraintViolation.getDynamicPayload(DynamicViolationParameter.class)).thenReturn(dynamicViolationParameter); + var expectedIssueParam = IssueParamTestFactory.create(); + + var issueParams = mapper.buildParameters(violation); + + assertThat(issueParams).usingRecursiveFieldByFieldElementComparator().contains(expectedIssueParam); + } + } + } +}