diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java index 3c4d607a..91d8c476 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java @@ -20,6 +20,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -34,8 +35,10 @@ import static org.mockito.Mockito.when; /** * Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful - * RUNNING transition and does NOT write it on a FAILED path. + * RUNNING transition and does NOT write it on a FAILED path (both the + * startContainer-throws path and the health-check-fails path). */ +@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2") class DeploymentSnapshotIT extends AbstractPostgresIT { @MockBean @@ -189,6 +192,53 @@ class DeploymentSnapshotIT extends AbstractPostgresIT { assertThat(failed.deployedConfigSnapshot()).isNull(); } + // ----------------------------------------------------------------------- + // Test 3: snapshot is NOT populated when the health check never passes. + // This exercises the early-exit path in DeploymentExecutor (line ~231) — + // startContainer succeeds, but no replica ever reports healthy, so + // waitForAnyHealthy returns 0 before the snapshot-write point. + // ----------------------------------------------------------------------- + + @Test + void snapshot_isNotPopulated_whenHealthCheckFails() throws Exception { + // --- given: container starts but never becomes healthy --- + String fakeContainerId = "fake-unhealthy-" + UUID.randomUUID(); + + when(runtimeOrchestrator.isEnabled()).thenReturn(true); + when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId); + when(runtimeOrchestrator.getContainerStatus(fakeContainerId)) + .thenReturn(new ContainerStatus("starting", true, 0, null)); + + String appSlug = "snap-unhealthy-" + UUID.randomUUID().toString().substring(0, 8); + post("/api/v1/environments/default/apps", String.format(""" + {"slug": "%s", "displayName": "Snapshot Unhealthy App"} + """, appSlug), operatorJwt); + put("/api/v1/environments/default/apps/" + appSlug + "/container-config", + """ + {"runtimeType": "spring-boot", "appPort": 8081} + """, operatorJwt); + String versionId = uploadJar(appSlug, ("fake-jar-unhealthy-" + appSlug).getBytes()); + + // --- when: trigger deploy --- + JsonNode deployResponse = post( + "/api/v1/environments/default/apps/" + appSlug + "/deployments", + String.format("{\"appVersionId\": \"%s\"}", versionId), operatorJwt); + String deploymentId = deployResponse.path("id").asText(); + + // --- await FAILED (healthchecktimeout overridden to 2s in @TestPropertySource) --- + await().atMost(30, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId)) + .orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId)); + assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED); + }); + + // --- then: snapshot is null (snapshot-write is gated behind health check) --- + Deployment failed = deploymentRepository.findById(UUID.fromString(deploymentId)).orElseThrow(); + assertThat(failed.deployedConfigSnapshot()).isNull(); + } + // ----------------------------------------------------------------------- // Helpers // -----------------------------------------------------------------------