From bfb2178b54f309298c912944da134934fa4506c4 Mon Sep 17 00:00:00 2001 From: Adriana Zencke Zimmermann Date: Sun, 22 Mar 2026 18:58:47 +0100 Subject: [PATCH 1/5] fix: return readable errors for unknown JSON fields Unknown fields in request payloads currently surface as generic parse failures, which makes API validation errors harder to understand and debug. Handle unreadable JSON with a clearer message that points to the exact field path and lists the allowed properties so invalid requests can be corrected quickly. --- .../configuration/GlobalExceptionHandler.java | 78 +++++++++++++++---- .../GlobalExceptionHandlerTest.java | 38 +++++++++ .../controller/MentorControllerTest.java | 59 ++++++++++++++ 3 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java index f838a7045..e2ad625d8 100644 --- a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java +++ b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java @@ -3,28 +3,18 @@ import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; -import com.wcc.platform.domain.exceptions.ApplicationMenteeWorkflowException; -import com.wcc.platform.domain.exceptions.ContentNotFoundException; -import com.wcc.platform.domain.exceptions.DuplicatedException; -import com.wcc.platform.domain.exceptions.EmailSendException; -import com.wcc.platform.domain.exceptions.ErrorDetails; -import com.wcc.platform.domain.exceptions.ForbiddenException; -import com.wcc.platform.domain.exceptions.InvalidProgramTypeException; -import com.wcc.platform.domain.exceptions.MemberNotFoundException; -import com.wcc.platform.domain.exceptions.MenteeNotSavedException; -import com.wcc.platform.domain.exceptions.MenteeRegistrationLimitException; -import com.wcc.platform.domain.exceptions.MentorNotFoundException; -import com.wcc.platform.domain.exceptions.MentorStatusException; -import com.wcc.platform.domain.exceptions.MentorshipCycleClosedException; -import com.wcc.platform.domain.exceptions.PlatformInternalException; -import com.wcc.platform.domain.exceptions.TemplateValidationException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import com.wcc.platform.domain.exceptions.*; import com.wcc.platform.repository.file.FileRepositoryException; import jakarta.validation.ConstraintViolationException; import java.util.NoSuchElementException; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -84,7 +74,7 @@ public ResponseEntity handleProgramTypeError( return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); } - /** Receive {@link DataAccessException} then return {@link HttpStatus#CONFLICT}. */ + /** Receive {@link DataIntegrityViolationException} then return {@link HttpStatus#CONFLICT}. */ @ExceptionHandler(DataIntegrityViolationException.class) @ResponseStatus(HttpStatus.CONFLICT) public ResponseEntity handleDataAccessException( @@ -155,6 +145,19 @@ public ResponseEntity handleMethodArgumentNotValidException( return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); } + /** Return 400 Bad Request for malformed JSON payloads. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity handleHttpMessageNotReadableException( + final HttpMessageNotReadableException ex, final WebRequest request) { + final var errorDetails = + new ErrorDetails( + HttpStatus.BAD_REQUEST.value(), + extractReadableMessage(ex), + request.getDescription(false)); + return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); + } + /** Return 403 Forbidden for ForbiddenException. */ @ExceptionHandler(ForbiddenException.class) public ResponseEntity handleForbiddenException( @@ -165,4 +168,47 @@ public ResponseEntity handleForbiddenException( return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); } + + private String extractReadableMessage(final HttpMessageNotReadableException ex) { + final var cause = ex.getMostSpecificCause(); + + if (cause instanceof UnrecognizedPropertyException unrecognizedProperty) { + final var allowedFields = + unrecognizedProperty.getKnownPropertyIds().stream() + .map(String::valueOf) + .sorted() + .collect(Collectors.joining(", ")); + + return "Unrecognized field '%s' at '%s'. Allowed fields: %s" + .formatted( + unrecognizedProperty.getPropertyName(), + formatPath(unrecognizedProperty.getPath()), + allowedFields); + } + + return cause.getMessage(); + } + + private String formatPath(final java.util.List path) { + if (path.isEmpty()) { + return "$"; + } + + return IntStream.range(0, path.size()) + .mapToObj(index -> formatPathReference(path.get(index), index == 0)) + .collect(Collectors.joining()); + } + + private String formatPathReference( + final JsonMappingException.Reference reference, final boolean firstReference) { + if (reference.getFieldName() != null) { + return firstReference ? reference.getFieldName() : "." + reference.getFieldName(); + } + + if (reference.getIndex() >= 0) { + return "[" + reference.getIndex() + "]"; + } + + return firstReference ? "$" : ""; + } } diff --git a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java index 6497c0728..8355cdb66 100644 --- a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java @@ -1,20 +1,25 @@ package com.wcc.platform.configuration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.FORBIDDEN; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wcc.platform.domain.exceptions.DuplicatedMemberException; import com.wcc.platform.domain.exceptions.ErrorDetails; import com.wcc.platform.domain.exceptions.ForbiddenException; +import com.wcc.platform.domain.platform.mentorship.TechnicalAreaProficiency; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -93,4 +98,37 @@ void shouldReturnConflictForDataAccessException() { assertEquals(CONFLICT, response.getStatusCode()); assertEquals(expectation, response.getBody()); } + + @Test + @DisplayName( + "Given malformed JSON with unknown field, " + + "when handling, then return BAD_REQUEST with field context") + void shouldReturnBadRequestForHttpMessageNotReadableException() throws Exception { + var invalidJson = + """ + { + "name": "BACKEND", + "proficiencyLevel": "BEGINNER" + } + """; + var cause = + assertThrows( + com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException.class, + () -> new ObjectMapper().readValue(invalidJson, TechnicalAreaProficiency.class)); + var exception = + new HttpMessageNotReadableException( + "JSON parse error", cause, new MockHttpInputMessage(invalidJson.getBytes())); + + var response = + globalExceptionHandler.handleHttpMessageNotReadableException(exception, webRequest); + + var expectation = + new ErrorDetails( + BAD_REQUEST.value(), + "Unrecognized field 'name' at 'name'. " + + "Allowed fields: proficiencyLevel, technicalArea", + DETAILS); + assertEquals(BAD_REQUEST, response.getStatusCode()); + assertEquals(expectation, response.getBody()); + } } diff --git a/src/test/java/com/wcc/platform/controller/MentorControllerTest.java b/src/test/java/com/wcc/platform/controller/MentorControllerTest.java index 3a426ea4e..89eda513e 100644 --- a/src/test/java/com/wcc/platform/controller/MentorControllerTest.java +++ b/src/test/java/com/wcc/platform/controller/MentorControllerTest.java @@ -78,6 +78,65 @@ void shouldCreateMentorAndReturnCreated() throws Exception { .andExpect(jsonPath("$.profileStatus", is("PENDING"))); } + @Test + @DisplayName( + "Given mentor payload with unknown nested field, " + + "when creating mentor, then return 400 with field context") + void shouldReturnBadRequestWithFieldContextForUnknownJsonField() throws Exception { + var invalidMentorPayload = + """ + { + "fullName": "fullName MENTOR", + "position": "position MENTOR", + "email": "email@mentor", + "slackDisplayName": "slackDisplayName", + "country": { + "countryCode": "ES", + "countryName": "Spain" + }, + "city": "City", + "companyName": "Company name", + "images": [], + "network": [], + "bio": "Mentor bio", + "spokenLanguages": ["English"], + "skills": { + "yearsExperience": 2, + "areas": [ + { + "name": "BACKEND", + "proficiencyLevel": "BEGINNER" + } + ], + "languages": [], + "mentorshipFocus": [] + }, + "menteeSection": { + "idealMentee": "ideal mentee description", + "additional": "additional", + "longTerm": { + "maxMentees": 1, + "hoursPerMentee": 4 + }, + "adHoc": [] + } + } + """; + + mockMvc + .perform( + MockMvcRequestBuilders.post(API_MENTORS) + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON) + .content(invalidMentorPayload)) + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath( + "$.message", + is( + "Unrecognized field 'name' at 'skills.areas[0].name'. Allowed fields: proficiencyLevel, technicalArea"))); + } + @Test @DisplayName("Given valid mentor ID and DTO, when updating mentor, then return 200 OK") void shouldUpdateMentorAndReturnOk() throws Exception { From 3e1ed454a8b1b451e1dd253e8be85425beadd507 Mon Sep 17 00:00:00 2001 From: Adriana Zencke Zimmermann Date: Sun, 22 Mar 2026 19:13:24 +0100 Subject: [PATCH 2/5] test: remove unnecessary exception declaration from test method --- .../wcc/platform/configuration/GlobalExceptionHandlerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java index 8355cdb66..b92ad4171 100644 --- a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java @@ -103,7 +103,7 @@ void shouldReturnConflictForDataAccessException() { @DisplayName( "Given malformed JSON with unknown field, " + "when handling, then return BAD_REQUEST with field context") - void shouldReturnBadRequestForHttpMessageNotReadableException() throws Exception { + void shouldReturnBadRequestForHttpMessageNotReadableException() { var invalidJson = """ { From c839e3fab7e2f0f1a45be63ab48c0e6bfea00239 Mon Sep 17 00:00:00 2001 From: Adriana Zencke Zimmermann Date: Sun, 22 Mar 2026 19:31:15 +0100 Subject: [PATCH 3/5] test: cover fallback unreadable JSON handler path The new JSON error handler was only covered for unknown fields, which left the generic HttpMessageNotReadableException fallback behavior untested. Add a regression test for an invalid enum value so future changes do not accidentally alter the fallback error contract. --- .../GlobalExceptionHandlerTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java index b92ad4171..f6755ee49 100644 --- a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java @@ -9,6 +9,7 @@ import static org.springframework.http.HttpStatus.FORBIDDEN; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.wcc.platform.domain.exceptions.DuplicatedMemberException; import com.wcc.platform.domain.exceptions.ErrorDetails; import com.wcc.platform.domain.exceptions.ForbiddenException; @@ -131,4 +132,32 @@ void shouldReturnBadRequestForHttpMessageNotReadableException() { assertEquals(BAD_REQUEST, response.getStatusCode()); assertEquals(expectation, response.getBody()); } + + @Test + @DisplayName( + "Given malformed JSON with invalid enum value, " + + "when handling, then return BAD_REQUEST with fallback message") + void shouldReturnBadRequestWithFallbackMessageForInvalidEnumValue() { + var invalidJson = + """ + { + "technicalArea": "NOT_A_REAL_AREA", + "proficiencyLevel": "BEGINNER" + } + """; + var cause = + assertThrows( + InvalidFormatException.class, + () -> new ObjectMapper().readValue(invalidJson, TechnicalAreaProficiency.class)); + var exception = + new HttpMessageNotReadableException( + "JSON parse error", cause, new MockHttpInputMessage(invalidJson.getBytes())); + + var response = + globalExceptionHandler.handleHttpMessageNotReadableException(exception, webRequest); + + var expectation = new ErrorDetails(BAD_REQUEST.value(), cause.getMessage(), DETAILS); + assertEquals(BAD_REQUEST, response.getStatusCode()); + assertEquals(expectation, response.getBody()); + } } From 76cfd4b924cd83d7ac14b8c7a12d2d9a0933d42e Mon Sep 17 00:00:00 2001 From: Adriana Zencke Zimmermann Date: Mon, 23 Mar 2026 23:15:31 +0100 Subject: [PATCH 4/5] Update mentorship page with 2025 cycle testimonials The feedback section is refreshed with testimonials from the 2025 mentorship cycle, replacing the 2024 entries to keep the displayed content current and relevant for prospective mentors and mentees. The `link` field is also reordered before `items` in the mentor and mentee sections to match a consistent field ordering convention used across the CMS content schema. --- .../resources/init-data/mentorshipPage.json | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/resources/init-data/mentorshipPage.json b/src/main/resources/init-data/mentorshipPage.json index 10dd27452..67c93e97a 100644 --- a/src/main/resources/init-data/mentorshipPage.json +++ b/src/main/resources/init-data/mentorshipPage.json @@ -9,59 +9,59 @@ "mentorSection": { "title": "Become a Mentor", "description": "You should apply to be a mentor if you:", + "link": { + "label": "Join as a mentor", + "uri": "/mentorship/mentor-registration" + }, "items": [ "Want to extend your professional network", "Want to contribute to the community", "You are ready to share expertise", "You want to get a new perspective and learn from your mentees" - ], - "link": { - "label": "Join as a mentor", - "uri": "/mentorship/mentor-registration" - } + ] }, "menteeSection": { "title": "Become a Mentee", "description": "You should apply to be a mentee if you:", + "link": { + "label": "Check our mentors", + "uri": "/mentorship/mentors" + }, "items": [ "Want to start career in software engineering", "Want to find a better job", "Want to be promoted at work", "Want to apply for a leadership position", "Need support in advancing your career" - ], - "link": { - "label": "Check our mentors", - "uri": "/mentorship/mentors" - } + ] }, "feedbackSection": { "title": "What do participants think about our Mentorship Programme?", "feedbacks": [ { - "name": "Lucy", - "feedback": "It is great to be able to share my experience as a newbie in Tech with someone that has more years and experience in the industry. It has definitely made me feel more comfortable with been a completely beginner again and confident that, if a put the hours in, one day it will be pay off.", - "memberType": "Mentor", - "year": 2024 + "name": "Busra", + "feedback": "My session with my mentor exceeded my expectations. She's calm, caring, and incredibly knowledgeable. She offered valuable guidance not only on my career but also on life in general. My mentor truly embodies a growth mindset. I highly recommend her to any mentee seeking direction delivered with both compassion and wisdom.", + "memberType": "Mentee", + "year": 2025 }, { - "name": "Ana Smith", - "feedback": "I am exciting with this mentorship program. This is really help me to pursue my goals. I got encouragement, insights, knowledge from my mentor that makes me back on the track and focus to what I have now, upgrade it, then reach out my goals.", + "name": "Zayna", + "feedback": "My mentor really took the time to comprehend my mental barriers and gave personalised structure advice to move past them. I appreciated the mentors vulnerability in explaining similar scenarios they had been in to give me a sense of guidance and belonging. Their kindness and warmth during the session has really helped me to progress in my career due to feeling safe to open up to them.", "memberType": "Mentee", - "year": 2024 + "year": 2025 }, { - "name": "Jane", - "feedback": "My mentor has done an excellent job accommodating me and guiding me this year. This was my first experience as a mentee and it has taken time getting my footing. I entered this program because I felt isolated since I seemed to not have any clarity of my career and had a sense of general \"directionless\". Mentor has made me feel validated in my feeling and has helped me understand that my anxiety stems from not having a good understanding of the industry I hope to be a part of. Through our sessions and an exercise she gave me, I was gained a better perceptive on how exactly to direct my focus.", - "memberType": "Mentor", - "year": 2024 + "name": "Anonymous", + "feedback": "Just the simple act of presenting my profile and explaining my difficulties to a professional helped me structure the ideas and information in my head, and that alone was a big help. My mentor was caring, involved, and encouraging.", + "memberType": "Mentee", + "year": 2025 }, { - "name": "Anonymous", - "feedback": "It is an extremely helpful program and the mentor is quite open with matching my needs. She also actively encourages me with when and how to make job applications. She has helped with reviewing my CV and Linkedin profile. We have started work on my own personal project and collaboratively code on it, with the mentor asking questions to reinforce learning.", + "name": "Hanna", + "feedback": "I am delighted to have had the opportunity to work with my mentor. Her explanations are always clear and thorough, and her enthusiasm and passion for our profession are truly contagious.\n Working with her has not only been educational but also incredibly motivating. Her guidance and support have given me a significant boost in my professional development. I am sincerely grateful to her for the valuable insights and encouragement she has provided.\nThank you!", "memberType": "Mentee", "year": 2024 } ] } -} \ No newline at end of file +} From f957b1ca9b765c04a1281485de3612013ddf9f0c Mon Sep 17 00:00:00 2001 From: Adriana Zencke Zimmermann Date: Sun, 29 Mar 2026 20:17:56 +0200 Subject: [PATCH 5/5] chore: suppress PMD excessive imports in GlobalExceptionHandler --- .../configuration/GlobalExceptionHandler.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java index e2ad625d8..6dca3caf7 100644 --- a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java +++ b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java @@ -4,10 +4,26 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonMappingException.Reference; import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; -import com.wcc.platform.domain.exceptions.*; +import com.wcc.platform.domain.exceptions.ApplicationMenteeWorkflowException; +import com.wcc.platform.domain.exceptions.ContentNotFoundException; +import com.wcc.platform.domain.exceptions.DuplicatedException; +import com.wcc.platform.domain.exceptions.EmailSendException; +import com.wcc.platform.domain.exceptions.ErrorDetails; +import com.wcc.platform.domain.exceptions.ForbiddenException; +import com.wcc.platform.domain.exceptions.InvalidProgramTypeException; +import com.wcc.platform.domain.exceptions.MemberNotFoundException; +import com.wcc.platform.domain.exceptions.MenteeNotSavedException; +import com.wcc.platform.domain.exceptions.MenteeRegistrationLimitException; +import com.wcc.platform.domain.exceptions.MentorNotFoundException; +import com.wcc.platform.domain.exceptions.MentorStatusException; +import com.wcc.platform.domain.exceptions.MentorshipCycleClosedException; +import com.wcc.platform.domain.exceptions.PlatformInternalException; +import com.wcc.platform.domain.exceptions.TemplateValidationException; import com.wcc.platform.repository.file.FileRepositoryException; import jakarta.validation.ConstraintViolationException; +import java.util.List; import java.util.NoSuchElementException; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -22,6 +38,7 @@ import org.springframework.web.context.request.WebRequest; /** Global controller to handle all exceptions for the API. */ +@SuppressWarnings({"PMD.ExcessiveImports"}) @RestControllerAdvice public class GlobalExceptionHandler { @@ -189,7 +206,7 @@ private String extractReadableMessage(final HttpMessageNotReadableException ex) return cause.getMessage(); } - private String formatPath(final java.util.List path) { + private String formatPath(final List path) { if (path.isEmpty()) { return "$"; }