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:
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user