Add boot4-migration module: one Boot 3.5.16 service migrated to 4.1.2, with the real compile, start-up, silent and test failures captured at each step

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 05:57:26 +00:00
co-authored by Claude Sonnet 5.5
parent fd0cf9b527
commit 85074ed51a
45 changed files with 1021 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- THE "BEFORE" APP: a Spring Boot 3.5.16 service written the way most 3.x services are.
It is NOT part of any build in this repository; scripts/run-all.sh copies it to a scratch
directory and builds it there, first as-is and then with the parent bumped to 4.1.2. -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>migration-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<!-- Boot 3: Flyway needs only the library, Boot's auto-configuration was in the big autoconfigure jar -->
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
package com.ankurm.migration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,18 @@
package com.ankurm.migration;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/** Takes the auto-configured RestClient.Builder. RestClient itself lives in spring-web, so this always compiles. */
@Component
public class Gateway {
private final RestClient client;
public Gateway(RestClient.Builder builder) {
this.client = builder.baseUrl("http://localhost:9").build();
}
public String describe() {
return client.getClass().getSimpleName();
}
}
@@ -0,0 +1,6 @@
package com.ankurm.migration;
import java.time.Instant;
public record Greeting(long id, String title, Instant createdAt, String author) {
}
@@ -0,0 +1,62 @@
package com.ankurm.migration;
import java.time.Instant;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
private static final Logger log = LoggerFactory.getLogger(GreetingController.class);
private final GreetingService service;
private final JdbcTemplate jdbc;
private final Gateway gateway;
public GreetingController(GreetingService service, JdbcTemplate jdbc, Gateway gateway) {
this.service = service;
this.jdbc = jdbc;
this.gateway = gateway;
}
@GetMapping("/greetings/{id}")
public Greeting greeting(@PathVariable long id) {
log.info("served greeting {} ........................................", id);
return new Greeting(id, service.title(id), Instant.parse("2026-01-02T03:04:05Z"), "ankur");
}
/** Serialised by Jackson's default record handling (no custom serializer). */
@GetMapping("/summary")
public Summary summary() {
Summary s = new Summary();
s.setId(1);
s.setTitle("Hello");
s.setCreatedAt(Instant.parse("2026-01-02T03:04:05Z"));
s.setAuthor("ankur");
return s;
}
/** Reads the table that the Flyway migration V1 creates. */
@GetMapping("/greetings/count")
public Map<String, Object> count() {
Integer n = jdbc.queryForObject("select count(*) from greeting", Integer.class);
return Map.of("rows", n);
}
/** How many times the @Aspect has intercepted GreetingService. */
@GetMapping("/aspect")
public Map<String, Object> aspect() {
return Map.of("interceptions", TimingAspect.COUNT.get());
}
@GetMapping("/gateway")
public Map<String, Object> gateway() {
return Map.of("client", gateway.describe());
}
}
@@ -0,0 +1,10 @@
package com.ankurm.migration;
import org.springframework.stereotype.Service;
@Service
public class GreetingService {
public String title(long id) {
return "Hello #" + id;
}
}
@@ -0,0 +1,22 @@
package com.ankurm.migration;
import java.io.IOException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
/** Boot 3 idiom: a Jackson 2 serializer registered through Boot's @JsonComponent. */
@JsonComponent
public class JsonConfig extends JsonSerializer<Greeting> {
@Override
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
gen.writeStartObject();
gen.writeNumberField("id", g.id());
gen.writeStringField("title", g.title());
gen.writeStringField("createdAt", g.createdAt().toString());
gen.writeStringField("author", g.author());
gen.writeEndObject();
}
}
@@ -0,0 +1,23 @@
package com.ankurm.migration;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic();
return http.build();
}
}
@@ -0,0 +1,24 @@
package com.ankurm.migration;
import java.time.Instant;
/**
* A plain bean (not a record) whose properties are deliberately NOT in alphabetical order.
* Jackson 2 wrote them in declaration order; Jackson 3 sorts bean properties alphabetically by default.
* (Records are unaffected: creator parameters are always written first, in constructor order.)
*/
public class Summary {
private long id;
private String title;
private Instant createdAt;
private String author;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
}
@@ -0,0 +1,21 @@
package com.ankurm.migration;
import java.util.concurrent.atomic.AtomicInteger;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/** Needs AspectJ weaving support on the classpath: spring-boot-starter-aop in Boot 3. */
@Aspect
@Component
public class TimingAspect {
static final AtomicInteger COUNT = new AtomicInteger();
@Around("execution(* com.ankurm.migration.GreetingService.*(..))")
public Object count(ProceedingJoinPoint pjp) throws Throwable {
COUNT.incrementAndGet();
return pjp.proceed();
}
}
@@ -0,0 +1,7 @@
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always
# Boot 3 logging properties that were deprecated for years (replaced by logging.logback.rollingpolicy.*)
logging.file.name=${LOG_DIR:target/logs}/app.log
logging.file.max-size=2KB
logging.file.max-history=50
@@ -0,0 +1,2 @@
create table greeting (id bigint primary key, title varchar(100) not null);
insert into greeting values (1, 'first'), (2, 'second'), (3, 'third');
@@ -0,0 +1,24 @@
package com.ankurm.migration;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
/** Prints the status of a Basic-authenticated POST without a CSRF token (to compare 3.5 with 4.1). */
@SpringBootTest(properties = "spring.security.user.password=pw")
@AutoConfigureMockMvc
class CsrfProbeTest {
@Autowired MockMvc mvc;
@Test
void postWithoutCsrfToken() throws Exception {
int status = mvc.perform(post("/anything").with(httpBasic("user", "pw"))).andReturn().getResponse().getStatus();
System.out.println("CSRFPROBE status=" + status);
}
}
@@ -0,0 +1,27 @@
package com.ankurm.migration;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class GreetingControllerTest {
@Autowired MockMvc mvc;
@MockBean GreetingService service;
@Test
void greetingUsesService() throws Exception {
given(service.title(7)).willReturn("mocked");
mvc.perform(get("/greetings/7")).andExpect(status().isOk()).andExpect(jsonPath("$.title").value("mocked"));
}
}
@@ -0,0 +1,22 @@
package com.ankurm.migration;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.boot.test.context.SpringBootTest;
/**
* A plain Mockito @Mock field inside a @SpringBootTest, with no MockitoExtension.
* Boot 3 initialised it through MockitoTestExecutionListener; Boot 4 removed that listener.
*/
@SpringBootTest
class MockFieldTest {
@Mock Runnable task;
@Test
void mockFieldIsInitialised() {
assertNotNull(task, "@Mock field was never initialised");
}
}