diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java new file mode 100644 index 00000000..f6b57474 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java @@ -0,0 +1,152 @@ +package com.cameleer3.server.app.storage; + +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.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 test proving that diagram_content_hash is populated during + * execution ingestion when a RouteGraph exists for the same route+agent. + */ +class DiagramLinkingIT extends AbstractClickHouseIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() { + // 1. Ingest a RouteGraph for route "diagram-link-route" via the diagrams endpoint + String graphJson = """ + { + "routeId": "diagram-link-route", + "description": "Linking test diagram", + "version": 1, + "nodes": [ + {"id": "n1", "type": "ENDPOINT", "label": "direct:start"}, + {"id": "n2", "type": "BEAN", "label": "myBean"} + ], + "edges": [ + {"source": "n1", "target": "n2", "edgeType": "FLOW"} + ], + "processorNodeMapping": {} + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Cameleer-Protocol-Version", "1"); + + ResponseEntity diagramResponse = restTemplate.postForEntity( + "/api/v1/data/diagrams", + new HttpEntity<>(graphJson, headers), + String.class); + assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + + // 2. Wait for diagram to be flushed to ClickHouse before ingesting execution + await().atMost(10, SECONDS).untilAsserted(() -> { + String hash = jdbcTemplate.queryForObject( + "SELECT content_hash FROM route_diagrams WHERE route_id = 'diagram-link-route' LIMIT 1", + String.class); + assertThat(hash).isNotNull().isNotEmpty(); + }); + + // 3. Ingest a RouteExecution for the same routeId + String executionJson = """ + { + "routeId": "diagram-link-route", + "exchangeId": "ex-diag-link-1", + "correlationId": "corr-diag-link-1", + "status": "COMPLETED", + "startTime": "2026-03-11T10:00:00Z", + "endTime": "2026-03-11T10:00:01Z", + "durationMs": 1000, + "processors": [ + { + "processorId": "proc-1", + "processorType": "bean", + "status": "COMPLETED", + "startTime": "2026-03-11T10:00:00Z", + "endTime": "2026-03-11T10:00:00.500Z", + "durationMs": 500, + "children": [] + } + ] + } + """; + + ResponseEntity execResponse = restTemplate.postForEntity( + "/api/v1/data/executions", + new HttpEntity<>(executionJson, headers), + String.class); + assertThat(execResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + + // 4. Verify diagram_content_hash is a non-empty SHA-256 hash (64 hex chars) + await().atMost(10, SECONDS).untilAsserted(() -> { + String hash = jdbcTemplate.queryForObject( + "SELECT diagram_content_hash FROM route_executions WHERE route_id = 'diagram-link-route'", + String.class); + assertThat(hash) + .isNotNull() + .isNotEmpty() + .hasSize(64) // SHA-256 hex = 64 characters + .matches("[a-f0-9]{64}"); + }); + } + + @Test + void diagramHashEmpty_whenNoRouteGraphExists() { + // Ingest a RouteExecution for a route with NO prior diagram + String executionJson = """ + { + "routeId": "no-diagram-route", + "exchangeId": "ex-no-diag-1", + "correlationId": "corr-no-diag-1", + "status": "COMPLETED", + "startTime": "2026-03-11T10:00:00Z", + "endTime": "2026-03-11T10:00:01Z", + "durationMs": 1000, + "processors": [ + { + "processorId": "proc-no-diag", + "processorType": "log", + "status": "COMPLETED", + "startTime": "2026-03-11T10:00:00Z", + "endTime": "2026-03-11T10:00:00.500Z", + "durationMs": 500, + "children": [] + } + ] + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Cameleer-Protocol-Version", "1"); + + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/data/executions", + new HttpEntity<>(executionJson, headers), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + + // Verify diagram_content_hash is empty string (graceful fallback) + await().atMost(10, SECONDS).untilAsserted(() -> { + String hash = jdbcTemplate.queryForObject( + "SELECT diagram_content_hash FROM route_executions WHERE route_id = 'no-diagram-route'", + String.class); + assertThat(hash) + .isNotNull() + .isEmpty(); + }); + } +}