diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java index db154a39..7ba1074f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java @@ -166,6 +166,157 @@ class DiagramRenderControllerIT extends AbstractPostgresIT { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } + @Test + void findByAppAndRoute_returnsLatestDiagram_noLiveAgentPrereq() { + // The env-scoped /routes/{routeId}/diagram endpoint no longer depends + // on the agent registry — routes whose publishing agents have been + // removed must still resolve. The seed step stored a diagram for + // route "render-test-route" under app "test-group" / env "default", + // so the same lookup must succeed even though the registry-driven + // "find agents for app" path used to be a hard 404 prerequisite. + HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); + headers.set("Accept", "application/json"); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("nodes"); + assertThat(response.getBody()).contains("edges"); + } + + @Test + void findByAppAndRoute_returns404ForUnknownRoute() { + HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); + headers.set("Accept", "application/json"); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/environments/default/apps/test-group/routes/nonexistent-route/diagram", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void exchangeDiagramHash_pinsPointInTimeEvenAfterNewerVersion() throws Exception { + // Point-in-time guarantee: an execution's stored diagramContentHash + // must keep resolving to the route shape captured at execution time, + // even after a newer diagram version for the same route is stored. + // Content-hash addressing + never-delete of route_diagrams makes this + // automatic — this test locks the invariant in. + HttpHeaders viewerHeaders = securityHelper.authHeadersNoBody(viewerJwt); + viewerHeaders.set("Accept", "application/json"); + + // Snapshot the pinned v1 render via the flat content-hash endpoint + // BEFORE a newer version is stored, so the post-v2 fetch can compare + // byte-for-byte. + ResponseEntity pinnedBefore = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class, + contentHash); + assertThat(pinnedBefore.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Also snapshot the by-route "latest" render for the same route. + ResponseEntity latestBefore = restTemplate.exchange( + "/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram", + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class); + assertThat(latestBefore.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Store a materially different v2 for the same (app, env, route). + // The renderer walks the `root` tree (not the legacy flat `nodes` + // list that the seed payload uses), so v2 uses the tree shape and + // will render non-empty output — letting us detect the version flip. + String newerDiagramJson = """ + { + "routeId": "render-test-route", + "description": "v2 with extra step", + "version": 2, + "root": { + "id": "n1", + "type": "ENDPOINT", + "label": "timer:tick-v2", + "children": [ + { + "id": "n2", + "type": "BEAN", + "label": "myBeanV2", + "children": [ + { + "id": "n3", + "type": "TO", + "label": "log:out-v2", + "children": [ + {"id": "n4", "type": "TO", "label": "log:audit"} + ] + } + ] + } + ] + }, + "edges": [ + {"source": "n1", "target": "n2", "edgeType": "FLOW"}, + {"source": "n2", "target": "n3", "edgeType": "FLOW"}, + {"source": "n3", "target": "n4", "edgeType": "FLOW"} + ] + } + """; + restTemplate.postForEntity( + "/api/v1/data/diagrams", + new HttpEntity<>(newerDiagramJson, securityHelper.authHeaders(jwt)), + String.class); + + // Invariant 1: The execution's stored diagramContentHash must not + // drift — exchanges stay pinned to the version captured at ingest. + ResponseEntity detailAfter = restTemplate.exchange( + "/api/v1/environments/default/executions?correlationId=render-probe-corr", + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class); + JsonNode search = objectMapper.readTree(detailAfter.getBody()); + String execId = search.get("data").get(0).get("executionId").asText(); + ResponseEntity exec = restTemplate.exchange( + "/api/v1/executions/" + execId, + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class); + JsonNode execBody = objectMapper.readTree(exec.getBody()); + assertThat(execBody.path("diagramContentHash").asText()).isEqualTo(contentHash); + + // Invariant 2: The pinned render (by H1) must be byte-identical + // before and after v2 is stored — content-hash addressing is stable. + ResponseEntity pinnedAfter = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class, + contentHash); + assertThat(pinnedAfter.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(pinnedAfter.getBody()).isEqualTo(pinnedBefore.getBody()); + + // Invariant 3: The by-route "latest" endpoint must now surface v2, + // so its body differs from the pre-v2 snapshot. Retry briefly to + // absorb the diagram-ingest flush path. + await().atMost(20, SECONDS).untilAsserted(() -> { + ResponseEntity latestAfter = restTemplate.exchange( + "/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram", + HttpMethod.GET, + new HttpEntity<>(viewerHeaders), + String.class); + assertThat(latestAfter.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(latestAfter.getBody()).isNotEqualTo(latestBefore.getBody()); + assertThat(latestAfter.getBody()).contains("myBeanV2"); + }); + } + @Test void getWithNoAcceptHeader_defaultsToSvg() { HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java index 7effec1d..cca1cf95 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java @@ -154,6 +154,54 @@ class ClickHouseDiagramStoreIT { assertThat(retrieved.getDescription()).isEqualTo("v2"); } + @Test + void findLatestContentHashForAppRoute_returnsLatestAcrossInstances() throws InterruptedException { + // v1 published by one agent, v2 by a different agent. The app+env+route + // resolver must pick v2 regardless of which instance produced it, and + // must keep working even if neither instance is "live" anywhere. + RouteGraph v1 = buildGraph("evolving-route", "n-a"); + v1.setDescription("v1"); + RouteGraph v2 = buildGraph("evolving-route", "n-a", "n-b"); + v2.setDescription("v2"); + + store.store(new TaggedDiagram("publisher-old", "versioned-app", "default", v1)); + Thread.sleep(10); + store.store(new TaggedDiagram("publisher-new", "versioned-app", "default", v2)); + + Optional hashOpt = store.findLatestContentHashForAppRoute( + "versioned-app", "evolving-route", "default"); + assertThat(hashOpt).isPresent(); + + RouteGraph retrieved = store.findByContentHash(hashOpt.get()).orElseThrow(); + assertThat(retrieved.getDescription()).isEqualTo("v2"); + } + + @Test + void findLatestContentHashForAppRoute_isolatesByAppAndEnv() { + RouteGraph graph = buildGraph("shared-route", "node-1"); + store.store(new TaggedDiagram("a1", "app-alpha", "dev", graph)); + store.store(new TaggedDiagram("a2", "app-beta", "prod", graph)); + + // Same route id exists across two (app, env) combos. The resolver must + // return empty for a mismatch on either dimension. + assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "dev")) + .isPresent(); + assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "prod")) + .isEmpty(); + assertThat(store.findLatestContentHashForAppRoute("app-beta", "shared-route", "dev")) + .isEmpty(); + assertThat(store.findLatestContentHashForAppRoute("app-gamma", "shared-route", "dev")) + .isEmpty(); + } + + @Test + void findLatestContentHashForAppRoute_emptyInputsReturnEmpty() { + assertThat(store.findLatestContentHashForAppRoute(null, "r", "default")).isEmpty(); + assertThat(store.findLatestContentHashForAppRoute("app", null, "default")).isEmpty(); + assertThat(store.findLatestContentHashForAppRoute("app", "r", null)).isEmpty(); + assertThat(store.findLatestContentHashForAppRoute("", "r", "default")).isEmpty(); + } + @Test void findProcessorRouteMapping_extractsMapping() { // Build a graph with 3 nodes: root + 2 children