feat: add environment controller with CRUD endpoints

Implements POST/GET/PATCH/DELETE endpoints at /api/tenants/{tenantId}/environments
with DTOs, mapping helpers, and a Spring Boot integration test (TestContainers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 17:40:23 +02:00
parent 34e98ab176
commit 785bdab3d1
5 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/environments")
public class EnvironmentController {
private final EnvironmentService environmentService;
public EnvironmentController(EnvironmentService environmentService) {
this.environmentService = environmentService;
}
@PostMapping
public ResponseEntity<EnvironmentResponse> create(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@GetMapping
public ResponseEntity<List<EnvironmentResponse>> list(@PathVariable UUID tenantId) {
var environments = environmentService.listByTenantId(tenantId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(environments);
}
@GetMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> getById(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId) {
return environmentService.getById(environmentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PatchMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> update(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
@Valid @RequestBody UpdateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{environmentId}")
public ResponseEntity<Void> delete(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private EnvironmentResponse toResponse(EnvironmentEntity entity) {
return new EnvironmentResponse(
entity.getId(),
entity.getTenantId(),
entity.getSlug(),
entity.getDisplayName(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateEnvironmentRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.environment.dto;
import java.time.Instant;
import java.util.UUID;
public record EnvironmentResponse(
UUID id,
UUID tenantId,
String slug,
String displayName,
String status,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -0,0 +1,9 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateEnvironmentRequest(
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -0,0 +1,180 @@
package net.siegeln.cameleer.saas.environment;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class EnvironmentControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID tenantId;
@BeforeEach
void setUp() {
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
}
@Test
void createEnvironment_shouldReturn201() throws Exception {
var request = new CreateEnvironmentRequest("prod", "Production");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("prod"))
.andExpect(jsonPath("$.displayName").value("Production"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
void createEnvironment_duplicateSlug_shouldReturn409() throws Exception {
var request = new CreateEnvironmentRequest("staging", "Staging");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void listEnvironments_shouldReturnAll() throws Exception {
var request = new CreateEnvironmentRequest("dev", "Development");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("dev"));
}
@Test
void updateEnvironment_shouldReturn200() throws Exception {
var createRequest = new CreateEnvironmentRequest("qa", "QA");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createRequest)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.displayName").value("QA Updated"));
}
@Test
void deleteDefaultEnvironment_shouldReturn403() throws Exception {
var request = new CreateEnvironmentRequest("default", "Default");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isForbidden());
}
@Test
void createEnvironment_noAuth_shouldReturn401() throws Exception {
var request = new CreateEnvironmentRequest("no-auth", "No Auth");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}