feat: add tenant service, controller, and DTOs with TDD
CRUD operations for tenants with slug-based lookup, tier management, and audit logging. Integration tests verify 201/409/401 responses.
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
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.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/tenants")
|
||||||
|
public class TenantController {
|
||||||
|
|
||||||
|
private final TenantService tenantService;
|
||||||
|
|
||||||
|
public TenantController(TenantService tenantService) {
|
||||||
|
this.tenantService = tenantService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
// Extract actor ID from authentication credentials (Phase 1: userId stored as credentials)
|
||||||
|
UUID actorId = authentication.getCredentials() instanceof UUID uid
|
||||||
|
? uid : UUID.fromString(authentication.getCredentials().toString());
|
||||||
|
|
||||||
|
var entity = tenantService.create(request, actorId);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
|
||||||
|
return tenantService.getById(id)
|
||||||
|
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/by-slug/{slug}")
|
||||||
|
public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
|
||||||
|
return tenantService.getBySlug(slug)
|
||||||
|
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantResponse toResponse(TenantEntity entity) {
|
||||||
|
return new TenantResponse(
|
||||||
|
entity.getId(),
|
||||||
|
entity.getName(),
|
||||||
|
entity.getSlug(),
|
||||||
|
entity.getTier().name(),
|
||||||
|
entity.getStatus().name(),
|
||||||
|
entity.getCreatedAt(),
|
||||||
|
entity.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TenantService {
|
||||||
|
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public TenantService(TenantRepository tenantRepository, AuditService auditService) {
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
|
||||||
|
if (tenantRepository.existsBySlug(request.slug())) {
|
||||||
|
throw new IllegalArgumentException("Slug already taken");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new TenantEntity();
|
||||||
|
entity.setName(request.name());
|
||||||
|
entity.setSlug(request.slug());
|
||||||
|
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
|
||||||
|
entity.setStatus(TenantStatus.PROVISIONING);
|
||||||
|
|
||||||
|
var saved = tenantRepository.save(entity);
|
||||||
|
|
||||||
|
auditService.log(actorId, null, saved.getId(),
|
||||||
|
AuditAction.TENANT_CREATE, saved.getSlug(),
|
||||||
|
null, null, "SUCCESS", null);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<TenantEntity> getById(UUID id) {
|
||||||
|
return tenantRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<TenantEntity> getBySlug(String slug) {
|
||||||
|
return tenantRepository.findBySlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<TenantEntity> getByLogtoOrgId(String logtoOrgId) {
|
||||||
|
return tenantRepository.findByLogtoOrgId(logtoOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TenantEntity> listActive() {
|
||||||
|
return tenantRepository.findByStatus(TenantStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantEntity activate(UUID tenantId, UUID actorId) {
|
||||||
|
var entity = tenantRepository.findById(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
entity.setStatus(TenantStatus.ACTIVE);
|
||||||
|
var saved = tenantRepository.save(entity);
|
||||||
|
|
||||||
|
auditService.log(actorId, null, tenantId,
|
||||||
|
AuditAction.TENANT_UPDATE, entity.getSlug(),
|
||||||
|
null, null, "SUCCESS", null);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantEntity suspend(UUID tenantId, UUID actorId) {
|
||||||
|
var entity = tenantRepository.findById(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
entity.setStatus(TenantStatus.SUSPENDED);
|
||||||
|
var saved = tenantRepository.save(entity);
|
||||||
|
|
||||||
|
auditService.log(actorId, null, tenantId,
|
||||||
|
AuditAction.TENANT_SUSPEND, entity.getSlug(),
|
||||||
|
null, null, "SUCCESS", null);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record CreateTenantRequest(
|
||||||
|
@NotBlank @Size(max = 255) String name,
|
||||||
|
@NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug,
|
||||||
|
String tier
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record TenantResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
String slug,
|
||||||
|
String tier,
|
||||||
|
String status,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import net.siegeln.cameleer.saas.TestcontainersConfig;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
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)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class TenantControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private String getAuthToken() throws Exception {
|
||||||
|
var registerRequest = new net.siegeln.cameleer.saas.auth.dto.RegisterRequest(
|
||||||
|
"tenant-test-" + System.nanoTime() + "@example.com", "Test User", "password123");
|
||||||
|
|
||||||
|
var result = mockMvc.perform(post("/api/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(registerRequest)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
return objectMapper.readTree(result.getResponse().getContentAsString()).get("token").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createTenant_returns201() throws Exception {
|
||||||
|
String token = getAuthToken();
|
||||||
|
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/tenants")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.name").value("Test Org"))
|
||||||
|
.andExpect(jsonPath("$.tier").value("LOW"))
|
||||||
|
.andExpect(jsonPath("$.status").value("PROVISIONING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createTenant_returns409ForDuplicateSlug() throws Exception {
|
||||||
|
String token = getAuthToken();
|
||||||
|
String slug = "duplicate-slug-" + System.nanoTime();
|
||||||
|
var request = new CreateTenantRequest("First", slug, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/tenants")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/tenants")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createTenant_returns401WithoutToken() throws Exception {
|
||||||
|
var request = new CreateTenantRequest("Test", "no-auth-test", null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/tenants")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getTenant_returnsTenantById() throws Exception {
|
||||||
|
String token = getAuthToken();
|
||||||
|
String slug = "get-test-" + System.nanoTime();
|
||||||
|
var request = new CreateTenantRequest("Get Test", slug, null);
|
||||||
|
|
||||||
|
var createResult = mockMvc.perform(post("/api/tenants")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/tenants/" + id)
|
||||||
|
.header("Authorization", "Bearer " + token))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.slug").value(slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TenantServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TenantRepository tenantRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuditService auditService;
|
||||||
|
|
||||||
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantService = new TenantService(tenantRepository, auditService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_savesNewTenantWithCorrectFields() {
|
||||||
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID");
|
||||||
|
var actorId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
|
||||||
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var result = tenantService.create(request, actorId);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Acme Corp");
|
||||||
|
assertThat(result.getSlug()).isEqualTo("acme-corp");
|
||||||
|
assertThat(result.getTier()).isEqualTo(Tier.MID);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsForDuplicateSlug() {
|
||||||
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
|
||||||
|
|
||||||
|
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> tenantService.create(request, UUID.randomUUID()))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Slug already taken");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_logsAuditEvent() {
|
||||||
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
|
||||||
|
var actorId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
|
||||||
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
tenantService.create(request, actorId);
|
||||||
|
|
||||||
|
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||||
|
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||||
|
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.TENANT_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_defaultsToLowTier() {
|
||||||
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
|
||||||
|
|
||||||
|
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
|
||||||
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var result = tenantService.create(request, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getTier()).isEqualTo(Tier.LOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returnsTenant() {
|
||||||
|
var id = UUID.randomUUID();
|
||||||
|
var entity = new TenantEntity();
|
||||||
|
entity.setName("Test");
|
||||||
|
entity.setSlug("test");
|
||||||
|
|
||||||
|
when(tenantRepository.findById(id)).thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
var result = tenantService.getById(id);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().getName()).isEqualTo("Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBySlug_returnsTenant() {
|
||||||
|
var entity = new TenantEntity();
|
||||||
|
entity.setName("Test");
|
||||||
|
entity.setSlug("test");
|
||||||
|
|
||||||
|
when(tenantRepository.findBySlug("test")).thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
var result = tenantService.getBySlug("test");
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().getSlug()).isEqualTo("test");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user