feat(02-03): detail controller, tree reconstruction, processor snapshot endpoint

- Implement findRawById and findProcessorSnapshot in ClickHouseExecutionRepository
- DetailController with GET /executions/{id} returning nested processor tree
- GET /executions/{id}/processors/{index}/snapshot for per-processor exchange data
- 5 unit tests for tree reconstruction (linear, branching, multiple roots, empty)
- 6 integration tests for detail endpoint, snapshot, and 404 handling
- Added assertj and mockito test dependencies to core module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 16:29:53 +01:00
parent 82a190c8e2
commit 0615a9851d
5 changed files with 626 additions and 1 deletions

View File

@@ -0,0 +1,227 @@
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 detail and processor snapshot endpoints.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DetailControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
private String seededExecutionId;
/**
* Seed a route execution with a 3-level processor tree:
* root -> [child1, child2], child2 -> [grandchild]
*/
@BeforeAll
void seedTestData() {
String json = """
{
"routeId": "detail-test-route",
"exchangeId": "detail-ex-1",
"correlationId": "detail-corr-1",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:01Z",
"durationMs": 1000,
"errorMessage": "",
"errorStackTrace": "",
"processors": [
{
"processorId": "root-proc",
"processorType": "split",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:01Z",
"durationMs": 1000,
"diagramNodeId": "node-root",
"inputBody": "root-input-body",
"outputBody": "root-output-body",
"inputHeaders": {"Content-Type": "application/json"},
"outputHeaders": {"X-Result": "ok"},
"children": [
{
"processorId": "child1-proc",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.100Z",
"endTime": "2026-03-10T10:00:00.200Z",
"durationMs": 100,
"diagramNodeId": "node-child1",
"inputBody": "child1-input",
"outputBody": "child1-output",
"inputHeaders": {},
"outputHeaders": {}
},
{
"processorId": "child2-proc",
"processorType": "bean",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.200Z",
"endTime": "2026-03-10T10:00:00.800Z",
"durationMs": 600,
"diagramNodeId": "node-child2",
"inputBody": "child2-input",
"outputBody": "child2-output",
"inputHeaders": {},
"outputHeaders": {},
"children": [
{
"processorId": "grandchild-proc",
"processorType": "to",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.300Z",
"endTime": "2026-03-10T10:00:00.700Z",
"durationMs": 400,
"diagramNodeId": "node-gc",
"inputBody": "gc-input",
"outputBody": "gc-output",
"inputHeaders": {"X-GC": "true"},
"outputHeaders": {}
}
]
}
]
}
]
}
""";
ingest(json);
// Wait for flush and get the execution_id
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count() FROM route_executions WHERE route_id = 'detail-test-route'",
Integer.class);
assertThat(count).isGreaterThanOrEqualTo(1);
});
seededExecutionId = jdbcTemplate.queryForObject(
"SELECT execution_id FROM route_executions WHERE route_id = 'detail-test-route' LIMIT 1",
String.class);
}
@Test
void getDetail_returnsNestedProcessorTree() throws Exception {
ResponseEntity<String> response = detailGet("/" + seededExecutionId);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("executionId").asText()).isEqualTo(seededExecutionId);
assertThat(body.get("routeId").asText()).isEqualTo("detail-test-route");
assertThat(body.get("status").asText()).isEqualTo("COMPLETED");
assertThat(body.get("durationMs").asLong()).isEqualTo(1000);
// Check nested tree: 1 root
JsonNode processors = body.get("processors");
assertThat(processors).hasSize(1);
// Root has 2 children
JsonNode root = processors.get(0);
assertThat(root.get("processorId").asText()).isEqualTo("root-proc");
assertThat(root.get("processorType").asText()).isEqualTo("split");
assertThat(root.get("children")).hasSize(2);
// Child1 has no children
JsonNode child1 = root.get("children").get(0);
assertThat(child1.get("processorId").asText()).isEqualTo("child1-proc");
assertThat(child1.get("children")).isEmpty();
// Child2 has 1 grandchild
JsonNode child2 = root.get("children").get(1);
assertThat(child2.get("processorId").asText()).isEqualTo("child2-proc");
assertThat(child2.get("children")).hasSize(1);
JsonNode grandchild = child2.get("children").get(0);
assertThat(grandchild.get("processorId").asText()).isEqualTo("grandchild-proc");
assertThat(grandchild.get("children")).isEmpty();
}
@Test
void getDetail_includesDiagramContentHash() throws Exception {
ResponseEntity<String> response = detailGet("/" + seededExecutionId);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
// diagramContentHash should be present (may be empty string)
assertThat(body.has("diagramContentHash")).isTrue();
}
@Test
void getDetail_nonexistentId_returns404() {
ResponseEntity<String> response = detailGet("/nonexistent-execution-id");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getProcessorSnapshot_returnsExchangeData() throws Exception {
// Processor index 0 is root-proc
ResponseEntity<String> response = detailGet(
"/" + seededExecutionId + "/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("inputBody").asText()).isEqualTo("root-input-body");
assertThat(body.get("outputBody").asText()).isEqualTo("root-output-body");
assertThat(body.get("inputHeaders").asText()).contains("Content-Type");
assertThat(body.get("outputHeaders").asText()).contains("X-Result");
}
@Test
void getProcessorSnapshot_outOfBoundsIndex_returns404() {
ResponseEntity<String> response = detailGet(
"/" + seededExecutionId + "/processors/999/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getProcessorSnapshot_nonexistentExecution_returns404() {
ResponseEntity<String> response = detailGet(
"/nonexistent-id/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// --- 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> detailGet(String path) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
return restTemplate.exchange(
"/api/v1/executions" + path,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
}
}