Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass
Three companion modules verifying and rewriting the Boot 4.1.1 / Framework 7.0.9 story for three older articles: the javax->jakarta.validation namespace fix plus Jakarta Validation 3.1 record-validation clarification, ETag/ conditional-request APIs re-verified unchanged plus the starter rename, and RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called out. 19 real passing tests generate every transcript quoted from the three companion articles. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
+164
@@ -0,0 +1,164 @@
|
||||
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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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\":\"[email protected]\",\"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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user