From c1cae25db7118bed995cca529b32b959f8f2fcbc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:53:58 +0200 Subject: [PATCH] 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. --- .../saas/tenant/TenantController.java | 68 ++++++++++ .../cameleer/saas/tenant/TenantService.java | 84 ++++++++++++ .../saas/tenant/dto/CreateTenantRequest.java | 11 ++ .../saas/tenant/dto/TenantResponse.java | 14 ++ .../saas/tenant/TenantControllerTest.java | 109 ++++++++++++++++ .../saas/tenant/TenantServiceTest.java | 120 ++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java new file mode 100644 index 0000000..ad06203 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java @@ -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 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 getById(@PathVariable UUID id) { + return tenantService.getById(id) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/by-slug/{slug}") + public ResponseEntity 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() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java new file mode 100644 index 0000000..c751bc6 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -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 getById(UUID id) { + return tenantRepository.findById(id); + } + + public Optional getBySlug(String slug) { + return tenantRepository.findBySlug(slug); + } + + public Optional getByLogtoOrgId(String logtoOrgId) { + return tenantRepository.findByLogtoOrgId(logtoOrgId); + } + + public List 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; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java new file mode 100644 index 0000000..b9bed27 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java @@ -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 +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java new file mode 100644 index 0000000..b008a09 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java @@ -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 +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java new file mode 100644 index 0000000..a663583 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java @@ -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)); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java new file mode 100644 index 0000000..b952cfa --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java @@ -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"); + } +}