From 3f4349130e2933807ba9038c96a40abf2cb964c9 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 22:15:11 -0300 Subject: [PATCH 1/3] fix: negotiate structured pre-SSE request errors --- .../jobtracker/exception/GlobalExceptionHandler.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index 6f29533..c45d03e 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -74,7 +74,10 @@ public ResponseEntity> handleAccessDenied(AccessDeniedExcept return buildResponse(HttpStatus.FORBIDDEN, "Access denied"); } - @ExceptionHandler(MethodArgumentNotValidException.class) + @ExceptionHandler(value = MethodArgumentNotValidException.class, produces = { + MediaType.APPLICATION_PROBLEM_JSON_VALUE, + MediaType.APPLICATION_JSON_VALUE + }) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { Map fieldErrors = new HashMap<>(); for (FieldError error : ex.getBindingResult().getFieldErrors()) { @@ -104,7 +107,10 @@ public ResponseEntity> handleMethodNotSupported(HttpRequestM // Map an unparseable/malformed request body to 400 (not 500). Without this, the // generic Exception handler below catches HttpMessageNotReadableException (e.g. a // date that doesn't match the expected yyyy-MM-dd format) and returns 500. - @ExceptionHandler(HttpMessageNotReadableException.class) + @ExceptionHandler(value = HttpMessageNotReadableException.class, produces = { + MediaType.APPLICATION_PROBLEM_JSON_VALUE, + MediaType.APPLICATION_JSON_VALUE + }) public ResponseEntity> handleNotReadable(HttpMessageNotReadableException ex) { log.warn("event=MALFORMED_REQUEST_BODY message={}", ex.getMessage()); return buildResponse(HttpStatus.BAD_REQUEST, "Malformed or unreadable request body"); From b8e61f6a1d5194e16932603ffe2a5036aca1bdf9 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 22:15:13 -0300 Subject: [PATCH 2/3] test: cover assistant pre-SSE validation negotiation --- .../unit/AssistantRequestValidationTest.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/test/java/com/jobtracker/unit/AssistantRequestValidationTest.java diff --git a/src/test/java/com/jobtracker/unit/AssistantRequestValidationTest.java b/src/test/java/com/jobtracker/unit/AssistantRequestValidationTest.java new file mode 100644 index 0000000..099a8b6 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/AssistantRequestValidationTest.java @@ -0,0 +1,135 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.AssistantProperties; +import com.jobtracker.controller.AssistantController; +import com.jobtracker.exception.GlobalExceptionHandler; +import com.jobtracker.service.assistant.AssistantProviderErrorMapper; +import com.jobtracker.service.assistant.AssistantService; +import com.jobtracker.service.assistant.AssistantService.AssistantStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import reactor.core.publisher.Flux; + +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class AssistantRequestValidationTest { + + private AssistantService assistant; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + assistant = mock(AssistantService.class); + AssistantProviderErrorMapper errorMapper = mock(AssistantProviderErrorMapper.class); + AssistantProperties properties = new AssistantProperties(); + + mockMvc = standaloneSetup(new AssistantController(assistant, properties, errorMapper)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void missingConversationIdReturnsStructuredProblemBeforeSseStarts() throws Exception { + mockMvc.perform(post("/api/v1/assistant/chat") + .contentType(MediaType.APPLICATION_JSON) + .accept( + MediaType.TEXT_EVENT_STREAM, + MediaType.APPLICATION_PROBLEM_JSON, + MediaType.APPLICATION_JSON) + .content(""" + { + "message": "hello" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.fieldErrors.conversationId").value("Conversation ID is required")); + + verifyNoInteractions(assistant); + } + + @Test + void malformedConversationIdReturnsStructuredBadRequestBeforeSseStarts() throws Exception { + mockMvc.perform(post("/api/v1/assistant/chat") + .contentType(MediaType.APPLICATION_JSON) + .accept( + MediaType.TEXT_EVENT_STREAM, + MediaType.APPLICATION_PROBLEM_JSON, + MediaType.APPLICATION_JSON) + .content(""" + { + "conversationId": "definitely-not-a-uuid", + "message": "hello" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("Malformed or unreadable request body")); + + verifyNoInteractions(assistant); + } + + @Test + void blankMessageReturnsStructuredProblemBeforeSseStarts() throws Exception { + mockMvc.perform(post("/api/v1/assistant/chat") + .contentType(MediaType.APPLICATION_JSON) + .accept( + MediaType.TEXT_EVENT_STREAM, + MediaType.APPLICATION_PROBLEM_JSON, + MediaType.APPLICATION_JSON) + .content(""" + { + "conversationId": "5c970c0e-d6b9-4e3c-8120-f8d496d654a5", + "message": " " + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.fieldErrors.message").value("Message is required")); + + verifyNoInteractions(assistant); + } + + @Test + void validRequestStillNegotiatesSse() throws Exception { + when(assistant.stream(any(UUID.class), eq("hello"))) + .thenReturn(new AssistantStream(Flux.just("hello"), Set::of)); + + MvcResult result = mockMvc.perform(post("/api/v1/assistant/chat") + .contentType(MediaType.APPLICATION_JSON) + .accept( + MediaType.TEXT_EVENT_STREAM, + MediaType.APPLICATION_PROBLEM_JSON, + MediaType.APPLICATION_JSON) + .content(""" + { + "conversationId": "5c970c0e-d6b9-4e3c-8120-f8d496d654a5", + "message": "hello" + } + """)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andExpect(request().asyncStarted()) + .andReturn(); + + result.getAsyncResult(1_000); + assertThat(result.getResponse().getContentAsString()).contains("event:token"); + } +} From cbb3b650a87533bfc1d474e8e7d6526f29668042 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 22:22:13 -0300 Subject: [PATCH 3/3] fix: keep generic errors out of SSE media negotiation --- .../com/jobtracker/exception/GlobalExceptionHandler.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index c45d03e..7427544 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -116,7 +116,10 @@ public ResponseEntity> handleNotReadable(HttpMessageNotReada return buildResponse(HttpStatus.BAD_REQUEST, "Malformed or unreadable request body"); } - @ExceptionHandler(Exception.class) + @ExceptionHandler(value = Exception.class, produces = { + MediaType.APPLICATION_PROBLEM_JSON_VALUE, + MediaType.APPLICATION_JSON_VALUE + }) public ResponseEntity> handleGeneral(Exception ex) { log.error("event=UNEXPECTED_ERROR message={}", ex.getMessage(), ex); return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");