diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml index 3ee49526..cdaace6a 100644 --- a/cameleer3-server-app/pom.xml +++ b/cameleer3-server-app/pom.xml @@ -54,13 +54,7 @@ org.testcontainers testcontainers-clickhouse - 2.0.2 - test - - - org.testcontainers - junit-jupiter - 2.0.2 + 2.0.3 test diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java index a8f92b66..b2f6499f 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java @@ -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. + *

+ * 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 @@ 17 2.17.3 1.0-SNAPSHOT + 2.0.3