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:
@@ -54,13 +54,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-clickhouse</artifactId>
|
<artifactId>testcontainers-clickhouse</artifactId>
|
||||||
<version>2.0.2</version>
|
<version>2.0.3</version>
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.testcontainers</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<version>2.0.2</version>
|
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ import org.springframework.test.context.ActiveProfiles;
|
|||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
import org.testcontainers.clickhouse.ClickHouseContainer;
|
import org.testcontainers.clickhouse.ClickHouseContainer;
|
||||||
import org.testcontainers.junit.jupiter.Container;
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
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
|
* Uses Testcontainers to spin up a ClickHouse server and initializes the schema
|
||||||
* from {@code clickhouse/init/01-schema.sql} before the first test runs.
|
* from {@code clickhouse/init/01-schema.sql} before the first test runs.
|
||||||
* Subclasses get a {@link JdbcTemplate} for direct database assertions.
|
* 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)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
public abstract class AbstractClickHouseIT {
|
public abstract class AbstractClickHouseIT {
|
||||||
|
|
||||||
@Container
|
protected static final ClickHouseContainer CLICKHOUSE;
|
||||||
protected static final ClickHouseContainer CLICKHOUSE =
|
|
||||||
new ClickHouseContainer("clickhouse/clickhouse-server:25.3");
|
static {
|
||||||
|
CLICKHOUSE = new ClickHouseContainer("clickhouse/clickhouse-server:25.3");
|
||||||
|
CLICKHOUSE.start();
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
protected JdbcTemplate jdbcTemplate;
|
protected JdbcTemplate jdbcTemplate;
|
||||||
@@ -48,8 +49,12 @@ public abstract class AbstractClickHouseIT {
|
|||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
static void initSchema() throws Exception {
|
static void initSchema() throws Exception {
|
||||||
String sql = Files.readString(
|
// Surefire runs from the module directory; schema is in the project root
|
||||||
Path.of("clickhouse/init/01-schema.sql"), StandardCharsets.UTF_8);
|
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(
|
try (Connection conn = DriverManager.getConnection(
|
||||||
CLICKHOUSE.getJdbcUrl(),
|
CLICKHOUSE.getJdbcUrl(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS route_executions (
|
|||||||
duration_ms UInt64,
|
duration_ms UInt64,
|
||||||
correlation_id String,
|
correlation_id String,
|
||||||
exchange_id String,
|
exchange_id String,
|
||||||
error_message Nullable(String),
|
error_message String DEFAULT '',
|
||||||
error_stacktrace Nullable(String),
|
error_stacktrace String DEFAULT '',
|
||||||
-- Nested processor executions stored as parallel arrays
|
-- Nested processor executions stored as parallel arrays
|
||||||
processor_ids Array(String),
|
processor_ids Array(String),
|
||||||
processor_types Array(LowCardinality(String)),
|
processor_types Array(LowCardinality(String)),
|
||||||
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS route_executions (
|
|||||||
ENGINE = MergeTree()
|
ENGINE = MergeTree()
|
||||||
PARTITION BY toYYYYMMDD(start_time)
|
PARTITION BY toYYYYMMDD(start_time)
|
||||||
ORDER BY (agent_id, status, start_time, execution_id)
|
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;
|
SETTINGS ttl_only_drop_parts = 1;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS route_diagrams (
|
CREATE TABLE IF NOT EXISTS route_diagrams (
|
||||||
@@ -53,5 +53,5 @@ CREATE TABLE IF NOT EXISTS agent_metrics (
|
|||||||
ENGINE = MergeTree()
|
ENGINE = MergeTree()
|
||||||
PARTITION BY toYYYYMMDD(collected_at)
|
PARTITION BY toYYYYMMDD(collected_at)
|
||||||
ORDER BY (agent_id, metric_name, 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;
|
SETTINGS ttl_only_drop_parts = 1;
|
||||||
|
|||||||
1
pom.xml
1
pom.xml
@@ -29,6 +29,7 @@
|
|||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<jackson.version>2.17.3</jackson.version>
|
<jackson.version>2.17.3</jackson.version>
|
||||||
<cameleer3-common.version>1.0-SNAPSHOT</cameleer3-common.version>
|
<cameleer3-common.version>1.0-SNAPSHOT</cameleer3-common.version>
|
||||||
|
<testcontainers.version>2.0.3</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
|
|||||||
Reference in New Issue
Block a user