using EntKube.Provisioning.Domain; using EntKube.Provisioning.Features.MinioTenants; using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant; using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant; using EntKube.Provisioning.Infrastructure; using EntKube.SharedKernel.Domain; using FluentAssertions; using Moq; namespace EntKube.Provisioning.Tests.Features; /// /// Tests for MinIO Tenant provisioning — the full lifecycle of creating, configuring, /// and deleting MinIO tenants on a Kubernetes cluster. Unlike MinIO adoption (which /// discovers existing tenants), this provisions new tenants from scratch via the /// MinIO Operator's Tenant CRD. /// public class MinioTenantProvisioningTests { private readonly InMemoryMinioTenantRepository tenantRepository; private readonly Mock tenantClient; public MinioTenantProvisioningTests() { tenantRepository = new InMemoryMinioTenantRepository(); tenantClient = new Mock(); } // ─── Create Tenant ────────────────────────────────────────────────────────── [Fact] public async Task CreateTenant_WithValidConfig_ProvisionsTenantOnCluster() { // Arrange — A platform admin wants a new MinIO tenant with 4 servers, // each with 4 disks of 100Gi, using the ceph-block storage class. tenantClient.Setup(c => c.ApplyTenantAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); CreateMinioTenantRequest request = new( ClusterId: Guid.NewGuid(), KubeConfig: "kubeconfig-content", ContextName: "prod-context", Name: "platform-storage", Namespace: "minio-tenant-1", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") }); // Act Result result = await handler.HandleAsync(request); // Assert — Tenant created in domain and applied to K8s cluster. result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBe(Guid.Empty); IReadOnlyList all = await tenantRepository.GetAllAsync(); all.Should().HaveCount(1); all[0].Name.Should().Be("platform-storage"); all[0].Namespace.Should().Be("minio-tenant-1"); all[0].Status.Should().Be(MinioTenantStatus.Provisioning); all[0].Pools.Should().HaveCount(1); all[0].Pools[0].Servers.Should().Be(4); all[0].Pools[0].VolumesPerServer.Should().Be(4); // Verify the K8s client was called to apply the Tenant CRD. tenantClient.Verify(c => c.ApplyTenantAsync( "kubeconfig-content", "prod-context", It.Is(t => t.Name == "platform-storage"), It.IsAny()), Times.Once); } [Fact] public async Task CreateTenant_WithMultiplePools_AllPoolsApplied() { // Arrange — Heterogeneous pools: fast NVMe for hot data, HDD for warm. tenantClient.Setup(c => c.ApplyTenantAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); CreateMinioTenantRequest request = new( ClusterId: Guid.NewGuid(), KubeConfig: "config", ContextName: "ctx", Name: "multi-tier", Namespace: "minio-multi", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"), new(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk") }); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); IReadOnlyList all = await tenantRepository.GetAllAsync(); all[0].Pools.Should().HaveCount(2); } [Fact] public async Task CreateTenant_WithInvalidConfig_ReturnsFailure() { // Arrange — Zero servers is not valid. CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); CreateMinioTenantRequest request = new( ClusterId: Guid.NewGuid(), KubeConfig: "config", ContextName: "ctx", Name: "bad-tenant", Namespace: "minio-bad", Pools: new List { new(Servers: 0, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") }); // Act Result result = await handler.HandleAsync(request); // Assert — Domain validation catches the bad config. result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("server"); } [Fact] public async Task CreateTenant_KubernetesClientFails_ReturnsFailure() { // Arrange — Network issue or RBAC problem prevents applying the CRD. tenantClient.Setup(c => c.ApplyTenantAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Forbidden: RBAC denied")); CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); CreateMinioTenantRequest request = new( ClusterId: Guid.NewGuid(), KubeConfig: "config", ContextName: "ctx", Name: "tenant", Namespace: "minio-ns", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") }); // Act Result result = await handler.HandleAsync(request); // Assert — Failure reported without tenant being persisted. result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("RBAC"); IReadOnlyList all = await tenantRepository.GetAllAsync(); all.Should().BeEmpty(); } // ─── Configure Tenant ────────────────────────────────────────────────────── [Fact] public async Task ConfigureTenant_UpdatesPools_AndReapplies() { // Arrange — Existing tenant needs scale-out: from 4 to 8 servers. MinioTenant existing = MinioTenant.Create( Guid.NewGuid(), "platform-storage", "minio-tenant-1", new List { new(4, 4, "100Gi", "ceph-block") }); existing.MarkRunning(); await tenantRepository.AddAsync(existing); tenantClient.Setup(c => c.ApplyTenantAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); ConfigureMinioTenantRequest request = new( TenantId: existing.Id, KubeConfig: "config", ContextName: "ctx", Pools: new List { new(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") }); // Act Result result = await handler.HandleAsync(request); // Assert — Pools updated, status back to Provisioning, K8s re-applied. result.IsSuccess.Should().BeTrue(); MinioTenant? updated = await tenantRepository.GetByIdAsync(existing.Id); updated.Should().NotBeNull(); updated!.Pools[0].Servers.Should().Be(8); updated.Status.Should().Be(MinioTenantStatus.Provisioning); tenantClient.Verify(c => c.ApplyTenantAsync( "config", "ctx", It.Is(t => t.Pools[0].Servers == 8), It.IsAny()), Times.Once); } [Fact] public async Task ConfigureTenant_NotFound_ReturnsFailure() { // Arrange ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); ConfigureMinioTenantRequest request = new( TenantId: Guid.NewGuid(), KubeConfig: "config", ContextName: "ctx", Pools: new List { new(4, 4, "100Gi", "standard") }); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not found"); } // ─── Delete Tenant ────────────────────────────────────────────────────────── [Fact] public async Task DeleteTenant_RemovesFromClusterAndMarksDecommissioned() { // Arrange — Platform admin tears down a tenant. MinioTenant existing = MinioTenant.Create( Guid.NewGuid(), "old-tenant", "minio-old", new List { new(4, 4, "100Gi", "standard") }); existing.MarkRunning(); await tenantRepository.AddAsync(existing); tenantClient.Setup(c => c.DeleteTenantAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); DeleteMinioTenantRequest request = new( TenantId: existing.Id, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert — Marked decommissioned and deleted from K8s. result.IsSuccess.Should().BeTrue(); MinioTenant? tenant = await tenantRepository.GetByIdAsync(existing.Id); tenant.Should().NotBeNull(); tenant!.Status.Should().Be(MinioTenantStatus.Decommissioned); tenantClient.Verify(c => c.DeleteTenantAsync( "config", "ctx", "old-tenant", "minio-old", It.IsAny()), Times.Once); } [Fact] public async Task DeleteTenant_NotFound_ReturnsFailure() { // Arrange DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); DeleteMinioTenantRequest request = new(Guid.NewGuid(), "config", "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not found"); } }