api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff

Wires DirtyStateCalculator behind an HTTP endpoint on AppController.
Adds findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository,
registers DirtyStateCalculator as a Spring bean (with ObjectMapper for
JavaTimeModule support), and covers all three scenarios with IT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 22:35:35 +02:00
parent 24464c0772
commit 6591f2fde3
8 changed files with 353 additions and 16 deletions

View File

@@ -0,0 +1,235 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.dto.DirtyStateResponse;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Integration tests for GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state.
*
* <p>Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT).
* @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.</p>
*/
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AppDirtyStateIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
}
// -----------------------------------------------------------------------
// Test 1: no deployment ever → dirty=true, lastSuccessfulDeploymentId=null
// -----------------------------------------------------------------------
@Test
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
String appSlug = "ds-nodeploy-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS No Deploy\"}", appSlug),
operatorJwt);
uploadJar(appSlug, ("fake-jar-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.5}", operatorJwt);
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isNull();
}
// -----------------------------------------------------------------------
// Test 2: after a successful deploy with matching desired state → dirty=false
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_matchingDesiredState_returnsDirtyFalse() throws Exception {
String fakeContainerId = "fake-cid-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-clean-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Clean\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-clean-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.25}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Desired state matches what was deployed → dirty=false
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isFalse();
assertThat(body.differences()).isEmpty();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
}
// -----------------------------------------------------------------------
// Test 3: after successful deploy, config changed → dirty=true
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
String fakeContainerId = "fake-cid2-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-dirty-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Dirty\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-dirty-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.1}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Change samplingRate after deploy
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.9}", operatorJwt);
// Now desired state differs from snapshot → dirty=true
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
assertThat(body.differences()).isNotEmpty();
assertThat(body.differences())
.anyMatch(d -> d.field().contains("samplingRate"));
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private DirtyStateResponse getDirtyState(String envSlug, String appSlug) {
HttpHeaders headers = securityHelper.authHeaders(operatorJwt);
var response = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/dirty-state",
HttpMethod.GET,
new HttpEntity<>(headers),
DirtyStateResponse.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
return response.getBody();
}
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(
path, HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(path, HttpMethod.PUT, new HttpEntity<>(json, headers), String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override
public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}