test(01-03): add integration tests for health, OpenAPI, protocol version, forward compat, and TTL

- HealthControllerIT: health returns 200, no protocol header needed, TTL verified
- OpenApiIT: api-docs returns OpenAPI spec, swagger UI accessible
- ProtocolVersionIT: missing/wrong header returns 400, correct header passes, excluded paths work
- ForwardCompatIT: unknown JSON fields do not cause deserialization errors
- Fix testcontainers version to 2.0.3 (docker-java 3.7.0 for Docker Desktop 29.x compat)
- Fix ClickHouse schema: TTL with toDateTime() cast, non-nullable error columns for tokenbf_v1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 12:03:00 +01:00
parent b8a4739f72
commit 2d3fde3766
8 changed files with 220 additions and 20 deletions

View File

@@ -54,13 +54,7 @@
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-clickhouse</artifactId>
<version>2.0.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>2.0.2</version>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@@ -7,12 +7,9 @@ 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;
@@ -26,15 +23,19 @@ import java.sql.Statement;
* 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.
* <p>
* 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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@
<java.version>17</java.version>
<jackson.version>2.17.3</jackson.version>
<cameleer3-common.version>1.0-SNAPSHOT</cameleer3-common.version>
<testcontainers.version>2.0.3</testcontainers.version>
</properties>
<dependencyManagement>