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:
hsiegeln
2026-04-04 14:53:58 +02:00
parent 119034307c
commit c1cae25db7
6 changed files with 406 additions and 0 deletions

View File

@@ -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));
}
}

View File

@@ -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");
}
}