build(spring-cluster): migrate to Spring Boot 4.1.0#15
Conversation
Bump the spring-boot-starter-parent from 3.5.15 to 4.1.0. Two runtime blockers under Boot 4 (Spring Framework 7): - arcadedb-server pulls slf4j-jdk14, which Boot 4 rejects at startup as a competing SLF4J binding alongside Logback. Exclude it so Logback owns the binding. - TestRestTemplate is removed in Boot 4. Rewrite the two controller ITs to RestTestClient bound to the random server port via @LocalServerPort. mvn clean verify: BUILD SUCCESS (2 unit + 13 IT tests, 0 failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Code Review
This pull request upgrades Spring Boot to version 4.1.0, excludes the conflicting slf4j-jdk14 dependency, and refactors integration tests to use RestTestClient instead of TestRestTemplate. The review feedback recommends enhancing the test assertions by utilizing robust jsonPath assertions for JSON responses, adopting AssertJ's assertThat for clearer error messages, and removing unused imports.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| import org.springframework.test.web.servlet.client.RestTestClient; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; |
There was a problem hiding this comment.
Removed in be6ffa2 along with the jsonPath migration.
| @Test | ||
| void statusReportsThisNodeAsLeader() { | ||
| ResponseEntity<String> resp = rest.getForEntity("/api/cluster/status", String.class); | ||
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | ||
| assertTrue(resp.getBody().contains("\"node\":\"localhost\""), resp.getBody()); | ||
| assertTrue(resp.getBody().contains("\"leader\":true"), resp.getBody()); | ||
| rest.get().uri("/api/cluster/status") | ||
| .exchange() | ||
| .expectStatus().isOk() | ||
| .expectBody(String.class).value(body -> { | ||
| assertTrue(body.contains("\"node\":\"localhost\""), body); | ||
| assertTrue(body.contains("\"leader\":true"), body); | ||
| }); | ||
| } | ||
|
|
||
| @Test | ||
| void healthIsUp() { | ||
| ResponseEntity<String> resp = rest.getForEntity("/api/health", String.class); | ||
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | ||
| assertTrue(resp.getBody().contains("\"status\":\"UP\""), resp.getBody()); | ||
| rest.get().uri("/api/health") | ||
| .exchange() | ||
| .expectStatus().isOk() | ||
| .expectBody(String.class).value(body -> assertTrue(body.contains("\"status\":\"UP\""), body)); | ||
| } |
There was a problem hiding this comment.
Using raw string containment (assertTrue(body.contains(...))) to assert JSON responses is fragile because it is sensitive to formatting, whitespace, and field ordering changes. It can also lead to NullPointerExceptions if the response body is empty.
Since you are using RestTestClient (which extends/mimics WebTestClient), you can leverage the built-in .jsonPath(...) assertions. This is much more robust, idiomatic, and provides excellent error messages out of the box.
| @Test | |
| void statusReportsThisNodeAsLeader() { | |
| ResponseEntity<String> resp = rest.getForEntity("/api/cluster/status", String.class); | |
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | |
| assertTrue(resp.getBody().contains("\"node\":\"localhost\""), resp.getBody()); | |
| assertTrue(resp.getBody().contains("\"leader\":true"), resp.getBody()); | |
| rest.get().uri("/api/cluster/status") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> { | |
| assertTrue(body.contains("\"node\":\"localhost\""), body); | |
| assertTrue(body.contains("\"leader\":true"), body); | |
| }); | |
| } | |
| @Test | |
| void healthIsUp() { | |
| ResponseEntity<String> resp = rest.getForEntity("/api/health", String.class); | |
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | |
| assertTrue(resp.getBody().contains("\"status\":\"UP\""), resp.getBody()); | |
| rest.get().uri("/api/health") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> assertTrue(body.contains("\"status\":\"UP\""), body)); | |
| } | |
| @Test | |
| void statusReportsThisNodeAsLeader() { | |
| rest.get().uri("/api/cluster/status") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody() | |
| .jsonPath("$.node").isEqualTo("localhost") | |
| .jsonPath("$.leader").isEqualTo(true); | |
| } | |
| @Test | |
| void healthIsUp() { | |
| rest.get().uri("/api/health") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody() | |
| .jsonPath("$.status").isEqualTo("UP"); | |
| } |
There was a problem hiding this comment.
Done in be6ffa2 — both cluster assertions now use jsonPath ($.node, $.leader, $.status). Verified the controllers return these as top-level fields with matching types (leader is a boolean, status a String).
| import org.springframework.test.web.servlet.client.RestTestClient; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; |
There was a problem hiding this comment.
Done in be6ffa2 — import is now org.assertj.core.api.Assertions.assertThat.
| @Test | ||
| void collaborativeEndpointReturnsRunningShoes() { | ||
| ResponseEntity<String> resp = rest.getForEntity("/api/recommendations/collaborative/u1", String.class); | ||
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | ||
| assertTrue(resp.getBody().contains("Running Shoes"), resp.getBody()); | ||
| rest.get().uri("/api/recommendations/collaborative/u1") | ||
| .exchange() | ||
| .expectStatus().isOk() | ||
| .expectBody(String.class).value(body -> assertTrue(body.contains("Running Shoes"), body)); | ||
| } | ||
|
|
||
| @Test | ||
| void trendingEndpointReturnsOk() { | ||
| ResponseEntity<String> resp = rest.getForEntity("/api/recommendations/trending", String.class); | ||
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | ||
| assertTrue(resp.getBody().contains("Running Shoes"), resp.getBody()); | ||
| rest.get().uri("/api/recommendations/trending") | ||
| .exchange() | ||
| .expectStatus().isOk() | ||
| .expectBody(String.class).value(body -> assertTrue(body.contains("Running Shoes"), body)); | ||
| } |
There was a problem hiding this comment.
Using JUnit's assertTrue(body.contains(...)) can throw a NullPointerException if the body is null, and produces unhelpful error messages on failure (printing the entire body string). Using AssertJ's assertThat(body).contains(...) is safer, more idiomatic for Spring Boot tests, and provides highly readable failure diffs.
| @Test | |
| void collaborativeEndpointReturnsRunningShoes() { | |
| ResponseEntity<String> resp = rest.getForEntity("/api/recommendations/collaborative/u1", String.class); | |
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | |
| assertTrue(resp.getBody().contains("Running Shoes"), resp.getBody()); | |
| rest.get().uri("/api/recommendations/collaborative/u1") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> assertTrue(body.contains("Running Shoes"), body)); | |
| } | |
| @Test | |
| void trendingEndpointReturnsOk() { | |
| ResponseEntity<String> resp = rest.getForEntity("/api/recommendations/trending", String.class); | |
| assertEquals(HttpStatus.OK, resp.getStatusCode()); | |
| assertTrue(resp.getBody().contains("Running Shoes"), resp.getBody()); | |
| rest.get().uri("/api/recommendations/trending") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> assertTrue(body.contains("Running Shoes"), body)); | |
| } | |
| @Test | |
| void collaborativeEndpointReturnsRunningShoes() { | |
| rest.get().uri("/api/recommendations/collaborative/u1") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> assertThat(body).contains("Running Shoes")); | |
| } | |
| @Test | |
| void trendingEndpointReturnsOk() { | |
| rest.get().uri("/api/recommendations/trending") | |
| .exchange() | |
| .expectStatus().isOk() | |
| .expectBody(String.class).value(body -> assertThat(body).contains("Running Shoes")); | |
| } |
There was a problem hiding this comment.
Done in be6ffa2 — switched to AssertJ assertThat(body).contains(...). Kept the string-body approach here (rather than jsonPath) since these endpoints return a JSON array of maps and the assertion is a containment check.
Address review feedback on the Boot 4 migration tests: - ClusterEndpointsIT: assert the structured JSON responses with RestTestClient's jsonPath() instead of raw substring matching, and drop the now-unused assertTrue import. - RecommendationControllerIT: use AssertJ assertThat(body).contains(...) (null-safe, better failure messages) instead of assertTrue(body.contains). mvn clean verify: BUILD SUCCESS (2 unit + 13 IT, 0 failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Migrates the
spring-clusterexample module from Spring Boot 3.5.15 to 4.1.0 (Spring Framework 7, Jakarta EE 11).A prior straight version bump was reverted (
967e11f) because Boot 4 introduces two hard runtime blockers. Both are fixed here.Changes
pom.xml: parent3.5.15→4.1.0; excludeorg.slf4j:slf4j-jdk14fromarcadedb-server.RecommendationControllerITandClusterEndpointsITfrom the removedTestRestTemplateto Boot 4'sRestTestClient(bound to the random port via@LocalServerPort).Why these were needed
arcadedb-servertransitively pullsslf4j-jdk14. Boot 4 fails fast at startup when a competing SLF4J binding sits next to Logback (IllegalStateException: LoggerFactory is not a Logback LoggerContext...), aborting every@SpringBootTestand the app itself. Excluding it lets Logback own the binding.TestRestTemplateis removed in Boot 4 (NoClassDefFoundErrorat runtime). Replaced with the newRestTestClient.Not changed
No production logic changed — controllers,
EmbeddedArcadeDbServer(SmartLifecycle),@ConfigurationProperties, andApplicationRunnercarry over untouched. Boot 4 serializes MVC responses with Jackson 3 while ArcadeDB keeps Jackson 2 internally; harmless since the app does no direct Jackson.Verification
mvn clean verify→ BUILD SUCCESS🤖 Generated with Claude Code