test(diagrams): add removed-route + point-in-time coverage
Store-level: assert findLatestContentHashForAppRoute picks the newest
hash across publishing instances (proves the lookup survives agent
removal), isolates by (app, env), and returns empty for blank inputs.
Controller-level: assert the env-scoped /routes/{routeId}/diagram
endpoint resolves without a registry prerequisite, 404s for unknown
routes, and that an execution's stored diagramContentHash stays pinned
to the point-in-time version after a newer diagram is stored — the
"latest" endpoint flips to v2, the by-hash render remains byte-stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,157 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
|
|||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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
|
@Test
|
||||||
void getWithNoAcceptHeader_defaultsToSvg() {
|
void getWithNoAcceptHeader_defaultsToSvg() {
|
||||||
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
|
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
|
||||||
|
|||||||
@@ -154,6 +154,54 @@ class ClickHouseDiagramStoreIT {
|
|||||||
assertThat(retrieved.getDescription()).isEqualTo("v2");
|
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<String> 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
|
@Test
|
||||||
void findProcessorRouteMapping_extractsMapping() {
|
void findProcessorRouteMapping_extractsMapping() {
|
||||||
// Build a graph with 3 nodes: root + 2 children
|
// Build a graph with 3 nodes: root + 2 children
|
||||||
|
|||||||
Reference in New Issue
Block a user