Skip to content

build(spring-cluster): migrate to Spring Boot 4.1.0#15

Merged
robfrank merged 2 commits into
mainfrom
feat/spring-boot-4
Jun 24, 2026
Merged

build(spring-cluster): migrate to Spring Boot 4.1.0#15
robfrank merged 2 commits into
mainfrom
feat/spring-boot-4

Conversation

@robfrank

Copy link
Copy Markdown
Contributor

Summary

Migrates the spring-cluster example 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: parent 3.5.154.1.0; exclude org.slf4j:slf4j-jdk14 from arcadedb-server.
  • ITs: rewrite RecommendationControllerIT and ClusterEndpointsIT from the removed TestRestTemplate to Boot 4's RestTestClient (bound to the random port via @LocalServerPort).

Why these were needed

  1. Loggingarcadedb-server transitively pulls slf4j-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 @SpringBootTest and the app itself. Excluding it lets Logback own the binding.
  2. Test clientTestRestTemplate is removed in Boot 4 (NoClassDefFoundError at runtime). Replaced with the new RestTestClient.

Not changed

No production logic changed — controllers, EmbeddedArcadeDbServer (SmartLifecycle), @ConfigurationProperties, and ApplicationRunner carry 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 verifyBUILD SUCCESS

  • Surefire (unit): 2/2
  • Failsafe (ITs, incl. embedded ArcadeDB HA + Raft): 13/13
  • 0 failures, 0 errors

🤖 Generated with Claude Code

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>
@mergify

mergify Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the unused assertTrue import since we are migrating to jsonPath assertions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in be6ffa2 along with the jsonPath migration.

Comment on lines 31 to 48
@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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
@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");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Replace the JUnit assertTrue import with AssertJ's assertThat import.

Suggested change
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.assertj.core.api.Assertions.assertThat;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in be6ffa2 — import is now org.assertj.core.api.Assertions.assertThat.

Comment on lines 30 to 44
@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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
@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"));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@robfrank robfrank merged commit 37dd2c9 into main Jun 24, 2026
3 of 4 checks passed
@robfrank robfrank deleted the feat/spring-boot-4 branch June 24, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant