Files
spring-boot-demo/problem-details/src/test/java/com/ankurm/problems/AdviceContractTest.java
T
asmhatreandClaude Opus 5 926250e1a9 Add problem-details: global exception handling with RFC 9457
Companion code for "Global Exception Handling with ProblemDetail (RFC 9457)
in Spring Boot 4". Thirteen failures under five handling setups (Boot
defaults, the Boot flag, a ResponseEntityExceptionHandler advice, advice
plus an ErrorController, a catch-all ordered first), validation errors,
i18n, content negotiation, Security's 401/403, silent 500s and decoding
on the client. 16 tests pin the behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
2026-09-11 17:12:22 +00:00

101 lines
4.5 KiB
Java

package com.ankurm.problems;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The contract of the recommended setup (profiles advice + errors): every failure, including the
* ones produced outside Spring MVC, is application/problem+json with the right status.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"advice", "errors"})
class AdviceContractTest {
private static final String PROBLEM = "application/problem+json";
@LocalServerPort
int port;
@Test
void domainExceptionIs404WithStableType() throws Exception {
Http.Response r = Http.get(port, "/orders/999");
assertThat(r.status()).isEqualTo(404);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).contains("\"type\":\"https://ankurm.com/problems/order-not-found\"")
.contains("\"orderId\":999")
.contains("\"instance\":\"/orders/999\"");
}
@Test
void validationListsEveryViolationWithAPointer() throws Exception {
Http.Response r = Http.send(port, "POST", "/orders", "{\"sku\":\"\",\"quantity\":0,\"customerEmail\":\"nope\"}");
assertThat(r.status()).isEqualTo(400);
assertThat(r.contentType()).isEqualTo(PROBLEM);
// Order of the three is not stable between runs - assert membership, not position.
assertThat(r.body()).contains("\"pointer\":\"#/sku\"", "\"pointer\":\"#/quantity\"",
"\"pointer\":\"#/customerEmail\"");
}
@Test
void unexpectedExceptionDoesNotLeakItsMessage() throws Exception {
Http.Response r = Http.get(port, "/orders/boom");
assertThat(r.status()).isEqualTo(500);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).contains("\"errorId\":").doesNotContain("jdbc:").doesNotContain("orders_rw");
}
@Test
void filterExceptionReachesTheErrorControllerNotTheAdvice() throws Exception {
Http.Response r = Http.get(port, "/orders/1", "X-Tenant", "BAD!");
assertThat(r.status()).isEqualTo(500);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).doesNotContain("X-Tenant").doesNotContain("errorId");
}
@Test
void securityResponsesAreProblemsToo() throws Exception {
Http.Response anonymous = Http.get(port, "/admin/orders");
assertThat(anonymous.status()).isEqualTo(401);
assertThat(anonymous.contentType()).isEqualTo(PROBLEM);
assertThat(anonymous.headers().firstValue("WWW-Authenticate")).hasValue("Basic realm=\"orders\"");
Http.Response user = Http.get(port, "/admin/orders", "Authorization", Http.basic("user", "user"));
assertThat(user.status()).isEqualTo(403);
assertThat(user.contentType()).isEqualTo(PROBLEM);
}
@Test
void anAcceptOfApplicationXmlStillGetsJson() throws Exception {
// application/xml is not compatible with application/problem+xml, so the error falls back
// to problem+json - while a successful response to the same Accept header is XML.
assertThat(Http.get(port, "/orders/1", "Accept", "application/xml").contentType())
.startsWith("application/xml");
assertThat(Http.get(port, "/orders/999", "Accept", "application/xml").contentType())
.isEqualTo(PROBLEM);
assertThat(Http.get(port, "/orders/999", "Accept", "application/problem+xml").contentType())
.isEqualTo("application/problem+xml");
}
@Test
void anUnsatisfiableAcceptDoesNotTurnTheErrorInto406() throws Exception {
Http.Response r = Http.get(port, "/orders/999", "Accept", "image/png");
assertThat(r.status()).isEqualTo(404);
assertThat(r.contentType()).isEqualTo(PROBLEM);
}
@Test
void messageSourceOverridesTitleAndDetailPerLocale() throws Exception {
String body = "{\"sku\":\"SKU-2\",\"quantity\":3,\"customerEmail\":\"[email protected]\"}";
assertThat(Http.send(port, "POST", "/orders", body).body())
.contains("\"title\":\"Out of stock\"")
.contains("\"detail\":\"Only 0 unit(s) of SKU-2 are available.\"");
assertThat(Http.send(port, "POST", "/orders", body, "Accept-Language", "de").body())
.contains("\"title\":\"Nicht vorrätig\"");
}
}