feat(02-03): ClickHouse search engine, search controller, and 13 integration tests

- ClickHouseSearchEngine with dynamic WHERE clause building and LIKE escape
- SearchController with GET (basic filters) and POST (advanced JSON body)
- SearchBeanConfig wiring SearchEngine, SearchService, DetailService beans
- 13 integration tests covering all filter types, combinations, pagination, empty results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 16:23:20 +01:00
parent dcae89f404
commit 82a190c8e2
4 changed files with 604 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
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.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration tests for the search controller endpoints.
* Tests all filter types independently and in combination.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SearchControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Seed test data: Insert executions with varying statuses, times, durations,
* correlationIds, error messages, and exchange snapshot data.
*/
@BeforeAll
void seedTestData() {
// Execution 1: COMPLETED, short duration, no errors
ingest("""
{
"routeId": "search-route-1",
"exchangeId": "ex-search-1",
"correlationId": "corr-alpha",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:00.050Z",
"durationMs": 50,
"errorMessage": "",
"errorStackTrace": "",
"processors": [
{
"processorId": "proc-1",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:00.050Z",
"durationMs": 50,
"inputBody": "customer-123 order data",
"outputBody": "processed customer-123",
"inputHeaders": {"Content-Type": "application/json"},
"outputHeaders": {"X-Trace": "abc"}
}
]
}
""");
// Execution 2: FAILED with NullPointerException, medium duration
ingest("""
{
"routeId": "search-route-2",
"exchangeId": "ex-search-2",
"correlationId": "corr-beta",
"status": "FAILED",
"startTime": "2026-03-10T12:00:00Z",
"endTime": "2026-03-10T12:00:00.200Z",
"durationMs": 200,
"errorMessage": "NullPointerException in OrderService",
"errorStackTrace": "java.lang.NullPointerException\\n at com.example.OrderService.process(OrderService.java:42)",
"processors": []
}
""");
// Execution 3: RUNNING, long duration, different time window
ingest("""
{
"routeId": "search-route-3",
"exchangeId": "ex-search-3",
"correlationId": "corr-gamma",
"status": "RUNNING",
"startTime": "2026-03-11T08:00:00Z",
"endTime": "2026-03-11T08:00:01Z",
"durationMs": 1000,
"errorMessage": "",
"errorStackTrace": "",
"processors": []
}
""");
// Execution 4: FAILED with MyException in stack trace
ingest("""
{
"routeId": "search-route-4",
"exchangeId": "ex-search-4",
"correlationId": "corr-delta",
"status": "FAILED",
"startTime": "2026-03-10T14:00:00Z",
"endTime": "2026-03-10T14:00:00.300Z",
"durationMs": 300,
"errorMessage": "Processing failed",
"errorStackTrace": "com.example.MyException: something broke\\n at com.example.Handler.handle(Handler.java:10)",
"processors": [
{
"processorId": "proc-4",
"processorType": "bean",
"status": "FAILED",
"startTime": "2026-03-10T14:00:00Z",
"endTime": "2026-03-10T14:00:00.300Z",
"durationMs": 300,
"inputBody": "",
"outputBody": "",
"inputHeaders": {"Content-Type": "text/plain"},
"outputHeaders": {}
}
]
}
""");
// Insert 6 more COMPLETED executions for pagination testing (total = 10)
for (int i = 5; i <= 10; i++) {
ingest(String.format("""
{
"routeId": "search-route-%d",
"exchangeId": "ex-search-%d",
"correlationId": "corr-page-%d",
"status": "COMPLETED",
"startTime": "2026-03-10T15:00:%02d.000Z",
"endTime": "2026-03-10T15:00:%02d.100Z",
"durationMs": 100,
"errorMessage": "",
"errorStackTrace": "",
"processors": []
}
""", i, i, i, i, i));
}
// Wait for all data to flush
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count() FROM route_executions WHERE route_id LIKE 'search-route-%'",
Integer.class);
assertThat(count).isEqualTo(10);
});
}
@Test
void searchByStatus_returnsOnlyMatchingExecutions() throws Exception {
ResponseEntity<String> response = searchGet("?status=FAILED");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(2);
assertThat(body.get("offset").asInt()).isEqualTo(0);
assertThat(body.get("limit").asInt()).isEqualTo(50);
assertThat(body.get("data")).isNotNull();
body.get("data").forEach(item ->
assertThat(item.get("status").asText()).isEqualTo("FAILED"));
}
@Test
void searchByTimeRange_returnsOnlyExecutionsInRange() throws Exception {
// Only execution 1 and 2 are on 2026-03-10 before 13:00
ResponseEntity<String> response = searchGet(
"?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(2);
}
@Test
void searchByDuration_returnsOnlyMatchingExecutions() throws Exception {
// durationMin=100, durationMax=500 should match executions with 100, 200, 300 ms
ResponseEntity<String> response = searchPost("""
{
"durationMin": 100,
"durationMax": 500
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
// Exec 2 (200ms), Exec 4 (300ms), Execs 5-10 (100ms each = 6)
assertThat(body.get("total").asLong()).isEqualTo(8);
}
@Test
void searchByCorrelationId_returnsOnlyMatchingExecution() throws Exception {
ResponseEntity<String> response = searchGet("?correlationId=corr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
}
@Test
void fullTextSearchGlobal_findsMatchInErrorMessage() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "text": "NullPointerException" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
}
@Test
void fullTextSearchGlobal_returnsEmptyForNonexistent() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "text": "nonexistent-term-xyz-12345" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isZero();
assertThat(body.get("data")).isEmpty();
}
@Test
void fullTextSearchInBody_findsMatchInExchangeBody() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "textInBody": "customer-123" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-1");
}
@Test
void fullTextSearchInHeaders_findsMatchInExchangeHeaders() throws Exception {
// Content-Type appears in exec 1 and exec 4 headers
ResponseEntity<String> response = searchPost("""
{ "textInHeaders": "Content-Type" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
}
@Test
void fullTextSearchInErrors_findsMatchInStackTrace() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "textInErrors": "MyException" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-4");
}
@Test
void combinedFilters_statusAndText() throws Exception {
// Only FAILED + NullPointer = exec 2
ResponseEntity<String> response = searchPost("""
{
"status": "FAILED",
"text": "NullPointer"
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
}
@Test
void postAdvancedSearch_allFiltersWork() throws Exception {
ResponseEntity<String> response = searchPost("""
{
"status": "COMPLETED",
"timeFrom": "2026-03-10T09:00:00Z",
"timeTo": "2026-03-10T11:00:00Z",
"durationMin": 0,
"durationMax": 100,
"correlationId": "corr-alpha"
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
}
@Test
void pagination_worksCorrectly() throws Exception {
// Get all 10 executions with pagination: offset=2, limit=3
ResponseEntity<String> response = searchPost("""
{
"offset": 2,
"limit": 3
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(10);
assertThat(body.get("data").size()).isEqualTo(3);
assertThat(body.get("offset").asInt()).isEqualTo(2);
assertThat(body.get("limit").asInt()).isEqualTo(3);
}
@Test
void emptyResults_returnsCorrectEnvelope() throws Exception {
ResponseEntity<String> response = searchGet("?status=NONEXISTENT_STATUS");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("data")).isEmpty();
assertThat(body.get("total").asLong()).isZero();
assertThat(body.get("offset").asInt()).isEqualTo(0);
assertThat(body.get("limit").asInt()).isEqualTo(50);
}
// --- Helper methods ---
private void ingest(String json) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity("/api/v1/data/executions",
new HttpEntity<>(json, headers), String.class);
}
private ResponseEntity<String> searchGet(String queryString) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
return restTemplate.exchange(
"/api/v1/search/executions" + queryString,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
}
private ResponseEntity<String> searchPost(String jsonBody) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.POST,
new HttpEntity<>(jsonBody, headers),
String.class);
}
}