+ * Container lifecycle is managed manually (started once, shared across all test classes).
*/
-@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");
+ protected static final ClickHouseContainer CLICKHOUSE;
+
+ static {
+ CLICKHOUSE = new ClickHouseContainer("clickhouse/clickhouse-server:25.3");
+ CLICKHOUSE.start();
+ }
@Autowired
protected JdbcTemplate jdbcTemplate;
@@ -48,8 +49,12 @@ public abstract class AbstractClickHouseIT {
@BeforeAll
static void initSchema() throws Exception {
- String sql = Files.readString(
- Path.of("clickhouse/init/01-schema.sql"), StandardCharsets.UTF_8);
+ // Surefire runs from the module directory; schema is in the project root
+ Path schemaPath = Path.of("clickhouse/init/01-schema.sql");
+ if (!Files.exists(schemaPath)) {
+ schemaPath = Path.of("../clickhouse/init/01-schema.sql");
+ }
+ String sql = Files.readString(schemaPath, StandardCharsets.UTF_8);
try (Connection conn = DriverManager.getConnection(
CLICKHOUSE.getJdbcUrl(),
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
new file mode 100644
index 00000000..1f7f23fe
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
@@ -0,0 +1,50 @@
+package com.cameleer3.server.app.controller;
+
+import com.cameleer3.server.app.AbstractClickHouseIT;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration test for forward compatibility (API-05).
+ * Verifies that unknown JSON fields in request bodies do not cause deserialization errors.
+ */
+class ForwardCompatIT extends AbstractClickHouseIT {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void unknownFieldsInRequestBodyDoNotCauseError() {
+ // JSON body with an unknown field that should not cause a 400 deserialization error.
+ // Jackson is configured with fail-on-unknown-properties: false in application.yml.
+ // Without the ExecutionController (Plan 01-02), this returns 404 -- which is acceptable.
+ // The key assertion: it must NOT be 400 (i.e., Jackson did not reject unknown fields).
+ String jsonWithUnknownFields = """
+ {
+ "futureField": "value",
+ "anotherUnknown": 42
+ }
+ """;
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("X-Cameleer-Protocol-Version", "1");
+ var entity = new HttpEntity<>(jsonWithUnknownFields, headers);
+
+ var response = restTemplate.exchange(
+ "/api/v1/data/executions", HttpMethod.POST, entity, String.class);
+
+ // The interceptor passes (correct protocol header), and Jackson should not reject
+ // unknown fields. Without a controller, expect 404 (not 400 or 422).
+ assertThat(response.getStatusCode().value())
+ .as("Unknown JSON fields must not cause 400 or 422 deserialization error")
+ .isNotIn(400, 422);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/HealthControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/HealthControllerIT.java
new file mode 100644
index 00000000..c701af3b
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/HealthControllerIT.java
@@ -0,0 +1,47 @@
+package com.cameleer3.server.app.controller;
+
+import com.cameleer3.server.app.AbstractClickHouseIT;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for the health endpoint and ClickHouse TTL verification.
+ */
+class HealthControllerIT extends AbstractClickHouseIT {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void healthEndpointReturns200WithStatus() {
+ var response = restTemplate.getForEntity("/api/v1/health", String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).contains("status");
+ }
+
+ @Test
+ void healthEndpointDoesNotRequireProtocolVersionHeader() {
+ // Health should be accessible without X-Cameleer-Protocol-Version header
+ var response = restTemplate.getForEntity("/api/v1/health", String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ }
+
+ @Test
+ void ttlConfiguredOnRouteExecutions() {
+ String createTable = jdbcTemplate.queryForObject(
+ "SHOW CREATE TABLE route_executions", String.class);
+ assertThat(createTable).containsIgnoringCase("TTL");
+ assertThat(createTable).contains("toIntervalDay(30)");
+ }
+
+ @Test
+ void ttlConfiguredOnAgentMetrics() {
+ String createTable = jdbcTemplate.queryForObject(
+ "SHOW CREATE TABLE agent_metrics", String.class);
+ assertThat(createTable).containsIgnoringCase("TTL");
+ assertThat(createTable).contains("toIntervalDay(30)");
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenApiIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenApiIT.java
new file mode 100644
index 00000000..e474f2b8
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenApiIT.java
@@ -0,0 +1,32 @@
+package com.cameleer3.server.app.controller;
+
+import com.cameleer3.server.app.AbstractClickHouseIT;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for OpenAPI documentation endpoints.
+ */
+class OpenApiIT extends AbstractClickHouseIT {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void apiDocsReturnsOpenApiSpec() {
+ var response = restTemplate.getForEntity("/api/v1/api-docs", String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).contains("openapi");
+ assertThat(response.getBody()).contains("paths");
+ }
+
+ @Test
+ void swaggerUiIsAccessible() {
+ var response = restTemplate.getForEntity("/api/v1/swagger-ui/index.html", String.class);
+ // Swagger UI may return 200 directly or 302 redirect
+ assertThat(response.getStatusCode().value()).isIn(200, 302);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
new file mode 100644
index 00000000..bb782707
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
@@ -0,0 +1,71 @@
+package com.cameleer3.server.app.interceptor;
+
+import com.cameleer3.server.app.AbstractClickHouseIT;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for the protocol version interceptor.
+ */
+class ProtocolVersionIT extends AbstractClickHouseIT {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void requestWithoutProtocolHeaderReturns400() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ var entity = new HttpEntity<>("{}", headers);
+
+ var response = restTemplate.exchange(
+ "/api/v1/data/executions", HttpMethod.POST, entity, String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(400);
+ assertThat(response.getBody()).contains("Missing or unsupported X-Cameleer-Protocol-Version header");
+ }
+
+ @Test
+ void requestWithWrongProtocolVersionReturns400() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("X-Cameleer-Protocol-Version", "2");
+ var entity = new HttpEntity<>("{}", headers);
+
+ var response = restTemplate.exchange(
+ "/api/v1/data/executions", HttpMethod.POST, entity, String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(400);
+ }
+
+ @Test
+ void requestWithCorrectProtocolVersionPassesInterceptor() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("X-Cameleer-Protocol-Version", "1");
+ var entity = new HttpEntity<>("{}", headers);
+
+ var response = restTemplate.exchange(
+ "/api/v1/data/executions", HttpMethod.POST, entity, String.class);
+ // The interceptor should NOT reject this request (not 400 from interceptor).
+ // Without the controller (Plan 01-02), this will be 404 -- which is fine.
+ assertThat(response.getStatusCode().value()).isNotEqualTo(400);
+ }
+
+ @Test
+ void healthEndpointExcludedFromInterceptor() {
+ var response = restTemplate.getForEntity("/api/v1/health", String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ }
+
+ @Test
+ void apiDocsExcludedFromInterceptor() {
+ var response = restTemplate.getForEntity("/api/v1/api-docs", String.class);
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ }
+}
diff --git a/clickhouse/init/01-schema.sql b/clickhouse/init/01-schema.sql
index afa94849..ab56da70 100644
--- a/clickhouse/init/01-schema.sql
+++ b/clickhouse/init/01-schema.sql
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS route_executions (
duration_ms UInt64,
correlation_id String,
exchange_id String,
- error_message Nullable(String),
- error_stacktrace Nullable(String),
+ error_message String DEFAULT '',
+ error_stacktrace String DEFAULT '',
-- Nested processor executions stored as parallel arrays
processor_ids Array(String),
processor_types Array(LowCardinality(String)),
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS route_executions (
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(start_time)
ORDER BY (agent_id, status, start_time, execution_id)
-TTL start_time + INTERVAL 30 DAY
+TTL toDateTime(start_time) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;
CREATE TABLE IF NOT EXISTS route_diagrams (
@@ -53,5 +53,5 @@ CREATE TABLE IF NOT EXISTS agent_metrics (
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(collected_at)
ORDER BY (agent_id, metric_name, collected_at)
-TTL collected_at + INTERVAL 30 DAY
+TTL toDateTime(collected_at) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;
diff --git a/pom.xml b/pom.xml
index 8018883d..bca775b0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,7 @@