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
@@ -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,26 @@
package com.ankurm.migration;
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ser.std.StdSerializer;
/** Boot 4: Jackson 3 (tools.jackson), @JacksonComponent, and no checked IOException. */
@JacksonComponent
public class JsonConfig extends StdSerializer<Greeting> {
public JsonConfig() {
super(Greeting.class);
}
@Override
public void serialize(Greeting g, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
gen.writeStartObject();
gen.writeNumberProperty("id", g.id());
gen.writeStringProperty("title", g.title());
gen.writeStringProperty("createdAt", g.createdAt().toString());
gen.writeStringProperty("author", g.author());
gen.writeEndObject();
}
}
@@ -0,0 +1,23 @@
package com.ankurm.migration;
import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
@Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a
.requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
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
logging.file.name=${LOG_DIR:target/logs}/app.log
# Boot 4.1 removed the old logging.file.* rolling keys; these are the replacements
logging.logback.rollingpolicy.max-file-size=2KB
logging.logback.rollingpolicy.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.webmvc.test.autoconfigure.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.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class GreetingControllerTest {
@Autowired MockMvc mvc;
@MockitoBean 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
/** Boot 4 fix: Mockito's own extension initialises @Mock fields. */
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class MockFieldTest {
@Mock Runnable task;
@Test
void mockFieldIsInitialised() {
assertNotNull(task, "@Mock field was never initialised");
}
}
@@ -0,0 +1,50 @@
package com.ankurm.migration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
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.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
/** Pins the runtime behaviour the "breaks at runtime" checklist is about, so a regression fails the build. */
@SpringBootTest
@AutoConfigureMockMvc
class RuntimeFactsTest {
@Autowired MockMvc mvc;
@Autowired JdbcTemplate jdbc;
@Test
void flywayMigrationRan() {
assertThat(jdbc.queryForObject("select count(*) from greeting", Integer.class)).isEqualTo(3);
}
@Test
void aspectIntercepts() throws Exception {
mvc.perform(get("/greetings/1")).andExpect(status().isOk());
assertThat(TimingAspect.COUNT.get()).isPositive();
}
@Test
void restClientBuilderIsAutoConfigured() throws Exception {
mvc.perform(get("/gateway")).andExpect(status().isOk());
}
@Test
void metricsEndpointExists() throws Exception {
mvc.perform(get("/actuator/metrics").with(user("u")))
.andExpect(status().isOk());
}
@Test
void serializerIsTheJacksonComponent() throws Exception {
mvc.perform(get("/greetings/2")).andExpect(content().json("{\"id\":2,\"title\":\"Hello #2\"}"));
}
}