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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user