package com.ankurm.customvalidation; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; // Boot 4.1 moved this out of org.springframework.boot.test.autoconfigure.web.servlet into its own // package (confirmed by listing the real jar contents, not read off a migration guide) -- see // docs/03-mockmvc-autoconfigure-package-moved.md. import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class ValidationScenariosTest { @Autowired MockMvc mockMvc; @Test void classBasedSpamMessageRejected() throws Exception { var result = mockMvc.perform(post("/contact") .contentType("application/json") .content("{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}")) .andExpect(status().isBadRequest()) .andReturn(); String body = result.getResponse().getContentAsString(); assertThat(body).contains("Message contains 'spam'"); Transcript.write("01-class-based-spam-rejected.txt", "$ curl -s -X POST localhost:8080/contact \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: " + body + "\n"); } @Test void classBasedValidMessageAccepted() throws Exception { var result = mockMvc.perform(post("/contact") .contentType("application/json") .content("{\"email\":\"user@example.com\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}")) .andExpect(status().isOk()) .andReturn(); Transcript.write("02-class-based-valid-accepted.txt", "$ curl -s -X POST localhost:8080/contact \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"email\":\"user@example.com\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: " + result.getResponse().getContentAsString() + "\n"); } @Test void recordBasedSpamMessageRejectedButWithNoErrorBodyByDefault() throws Exception { var result = mockMvc.perform(post("/contact-record") .contentType("application/json") .content("{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}")) .andExpect(status().isBadRequest()) .andReturn(); String body = result.getResponse().getContentAsString(); // The controller has no BindingResult parameter for a record the way ContactController's // class-based method does, so a failing constraint throws MethodArgumentNotValidException // instead of populating a result object -- and Boot 4.1's default handler for that // exception, with no Accept header requesting a structured error body, returns an EMPTY // 400 body. This surprised me; verified below with an explicit Accept: application/json. assertThat(body).isEmpty(); Transcript.write("03-record-based-spam-rejected.txt", "$ curl -s -i -X POST localhost:8080/contact-record \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: '" + body + "' (empty!)\n" + "\n# The record parameter has no BindingResult to collect field errors into, unlike\n" + "# ContactController#submitContactForm's class-based, BindingResult-carrying signature.\n" + "# A failing constraint throws MethodArgumentNotValidException instead, and Boot 4.1's\n" + "# default handling for it returns an EMPTY body when the request has no Accept header\n" + "# asking for a structured error. See the next transcript for what changes with one.\n"); } @Test void recordBasedSpamMessageRejectedStillEmptyWithAcceptJson() throws Exception { // Checked directly rather than assumed: an Accept header alone does NOT turn on a // structured error body. See ProblemDetailsEnabledTest for the property that does. var result = mockMvc.perform(post("/contact-record") .contentType("application/json") .accept("application/json") .content("{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}")) .andExpect(status().isBadRequest()) .andReturn(); String body = result.getResponse().getContentAsString(); assertThat(body).isEmpty(); Transcript.write("03b-record-based-spam-rejected-accept-json.txt", "$ curl -s -X POST localhost:8080/contact-record \\\n" + " -H 'Content-Type: application/json' -H 'Accept: application/json' \\\n" + " -d '{\"email\":\"spammy@email.com\",\"message\":\"This is spam.\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Content-Type: " + result.getResponse().getContentType() + "\n" + "Body: '" + body + "' (still empty!)\n" + "\n# An Accept header alone changes nothing -- the body is still empty. What actually turns\n" + "# on a structured error body is the spring.mvc.problemdetails.enabled property, which is\n" + "# off by default and unrelated to content negotiation. See\n" + "# docs/output/07-problemdetails-enabled-record-rejected.txt for the same request with it on.\n"); } @Test void classBasedBadDateRangeRejected() throws Exception { var result = mockMvc.perform(post("/booking") .contentType("application/json") .content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}")) .andExpect(status().isBadRequest()) .andReturn(); Transcript.write("04-class-based-bad-date-range.txt", "$ curl -s -X POST localhost:8080/booking \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: " + result.getResponse().getContentAsString() + "\n"); } @Test void recordBasedBadDateRangeRejectedTheSameWay() throws Exception { var result = mockMvc.perform(post("/booking-record") .contentType("application/json") .content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}")) .andExpect(status().isBadRequest()) .andReturn(); Transcript.write("05-record-based-bad-date-range.txt", "$ curl -s -X POST localhost:8080/booking-record \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: " + result.getResponse().getContentAsString() + "\n" + "\n# Class-level @DateRangeValid, placed on the record's type declaration exactly as it would\n" + "# be on a class, is honoured the same way. The validator reads booking.startDate() /\n" + "# booking.endDate() (accessor methods) instead of getStartDate()/getEndDate().\n"); } @Test void recordBasedGoodDateRangeAccepted() throws Exception { var result = mockMvc.perform(post("/booking-record") .contentType("application/json") .content("{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}")) .andExpect(status().isOk()) .andReturn(); Transcript.write("06-record-based-good-date-range.txt", "$ curl -s -X POST localhost:8080/booking-record \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}'\n\n" + "HTTP status: " + result.getResponse().getStatus() + "\n" + "Body: " + result.getResponse().getContentAsString() + "\n"); } }