feat(01-03): add test infrastructure, protocol version interceptor, and app bootstrap

- AbstractClickHouseIT base class with Testcontainers ClickHouse and schema init
- ProtocolVersionInterceptor validates X-Cameleer-Protocol-Version:1 on data/agent paths
- WebConfig registers interceptor with path patterns, excludes health/docs
- Cameleer3ServerApplication with @EnableScheduling and component scanning
- application-test.yml with small buffer config for tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 11:53:31 +01:00
parent b2501f2937
commit b8a4739f72
5 changed files with 184 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
package com.cameleer3.server.app;
import com.cameleer3.server.app.config.IngestionConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Main entry point for the Cameleer3 Server application.
* <p>
* Scans {@code com.cameleer3.server.app} and {@code com.cameleer3.server.core} packages.
*/
@SpringBootApplication(scanBasePackages = {
"com.cameleer3.server.app",
"com.cameleer3.server.core"
})
@EnableScheduling
@EnableConfigurationProperties(IngestionConfig.class)
public class Cameleer3ServerApplication {
public static void main(String[] args) {
SpringApplication.run(Cameleer3ServerApplication.class, args);
}
}

View File

@@ -0,0 +1,34 @@
package com.cameleer3.server.app.config;
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC configuration.
* <p>
* Registers the {@link ProtocolVersionInterceptor} on data and agent endpoint paths,
* excluding health, API docs, and Swagger UI paths that do not require protocol versioning.
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ProtocolVersionInterceptor protocolVersionInterceptor;
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) {
this.protocolVersionInterceptor = protocolVersionInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(protocolVersionInterceptor)
.addPathPatterns("/api/v1/data/**", "/api/v1/agents/**")
.excludePathPatterns(
"/api/v1/health",
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/api/v1/swagger-ui.html"
);
}
}

View File

@@ -0,0 +1,46 @@
package com.cameleer3.server.app.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
/**
* Validates that all requests to data and agent endpoints include the
* {@code X-Cameleer-Protocol-Version} header with value {@code "1"}.
* <p>
* Requests missing the header or using an unsupported version receive a 400 response
* with a JSON error body.
*/
@Component
public class ProtocolVersionInterceptor implements HandlerInterceptor {
private static final String HEADER_NAME = "X-Cameleer-Protocol-Version";
private static final String SUPPORTED_VERSION = "1";
private final ObjectMapper objectMapper;
public ProtocolVersionInterceptor(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String version = request.getHeader(HEADER_NAME);
if (version == null || !SUPPORTED_VERSION.equals(version)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(),
Map.of("error", "Missing or unsupported X-Cameleer-Protocol-Version header"));
return false;
}
return true;
}
}

View File

@@ -0,0 +1,68 @@
package com.cameleer3.server.app;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.junit.jupiter.api.BeforeAll;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
/**
* Base class for integration tests requiring a ClickHouse instance.
* <p>
* Uses Testcontainers to spin up a ClickHouse server and initializes the schema
* from {@code clickhouse/init/01-schema.sql} before the first test runs.
* Subclasses get a {@link JdbcTemplate} for direct database assertions.
*/
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class AbstractClickHouseIT {
@Container
protected static final ClickHouseContainer CLICKHOUSE =
new ClickHouseContainer("clickhouse/clickhouse-server:25.3");
@Autowired
protected JdbcTemplate jdbcTemplate;
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", CLICKHOUSE::getJdbcUrl);
registry.add("spring.datasource.username", CLICKHOUSE::getUsername);
registry.add("spring.datasource.password", CLICKHOUSE::getPassword);
}
@BeforeAll
static void initSchema() throws Exception {
String sql = Files.readString(
Path.of("clickhouse/init/01-schema.sql"), StandardCharsets.UTF_8);
try (Connection conn = DriverManager.getConnection(
CLICKHOUSE.getJdbcUrl(),
CLICKHOUSE.getUsername(),
CLICKHOUSE.getPassword());
Statement stmt = conn.createStatement()) {
// Execute each statement separately (separated by semicolons)
for (String statement : sql.split(";")) {
String trimmed = statement.trim();
if (!trimmed.isEmpty()) {
stmt.execute(trimmed);
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
spring:
datasource:
url: jdbc:ch://placeholder:8123/cameleer3
username: default
password: ""
driver-class-name: com.clickhouse.jdbc.ClickHouseDriver
ingestion:
buffer-capacity: 100
batch-size: 10
flush-interval-ms: 100