env(admin): per-environment color field + V2 migration

- V2__add_environment_color.sql adds a CHECK-constrained VARCHAR color column (default 'slate'); existing rows backfill to slate.
- Environment record + EnvironmentColor constants (8 preset values) flow through repository, service, and admin API.
- UpdateEnvironmentRequest.color nullable: null preserves existing; unknown values → 400.
- ITs cover valid / invalid / null-preserves behaviour; existing Environment constructor call-sites updated with the new color arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 19:24:30 +02:00
parent 88b003d4f0
commit c2eab71a31
15 changed files with 147 additions and 18 deletions

View File

@@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest {
events = mock(AgentEventRepository.class);
envRepo = mock(EnvironmentRepository.class);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, Instant.EPOCH)));
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH)));
eval = new AgentLifecycleEvaluator(events, envRepo);
}

View File

@@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest {
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new RouteMetricEvaluator(statsStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
// ---- helpers ----
private Environment env() {
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH);
}
private AlertRule rule(ConditionKind kind) {

View File

@@ -88,9 +88,80 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
assertThat(body.path("production").asBoolean()).isFalse();
assertThat(body.path("enabled").asBoolean()).isTrue();
assertThat(body.path("color").asText()).isEqualTo("slate");
assertThat(body.has("id")).isTrue();
}
@Test
void updateEnvironment_withValidColor_persists() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-ok", "displayName": "Color OK", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-ok", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Color OK", "production": false, "enabled": true, "color": "amber"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("color").asText()).isEqualTo("amber");
}
@Test
void updateEnvironment_withNullColor_preservesExisting() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-preserve", "displayName": "Keep", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
// Set color to teal
restTemplate.exchange(
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Keep", "production": false, "enabled": true, "color": "teal"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
// Update without color field → teal preserved
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Still Keep", "production": false, "enabled": true}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("displayName").asText()).isEqualTo("Still Keep");
assertThat(body.path("color").asText()).isEqualTo("teal");
}
@Test
void updateEnvironment_withUnknownColor_returns400() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-bad", "displayName": "Bad", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-bad", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Bad", "production": false, "enabled": true, "color": "neon"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void updateEnvironment_asAdmin_returns200() throws Exception {
// Create an environment first