using EntKube.Provisioning.Domain; using EntKube.Provisioning.Features.MinioTenants; using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants; using EntKube.Provisioning.Infrastructure; using EntKube.SharedKernel.Domain; using FluentAssertions; using Moq; namespace EntKube.Provisioning.Tests.Features; /// /// Tests for MinIO Tenant adoption — discovering existing MinIO Tenant CRDs on a /// Kubernetes cluster and importing them into the platform's domain so they can be /// viewed, managed, and monitored. This follows the same pattern as CNPG cluster /// adoption: the Provisioning service scans the cluster, finds what's already running, /// and creates domain aggregates for each tenant. /// public class MinioTenantAdoptionTests { private readonly InMemoryMinioTenantRepository tenantRepository; private readonly Mock tenantClient; public MinioTenantAdoptionTests() { tenantRepository = new InMemoryMinioTenantRepository(); tenantClient = new Mock(); } // ─── Adopt Tenants ────────────────────────────────────────────────────────── [Fact] public async Task AdoptTenants_WithExistingTenants_ImportsThemAsRunning() { // Arrange — The Kubernetes cluster has two MinIO tenants already running: // one for platform storage and one for application data. Neither of them // exists in our repository yet, so adoption should discover and import both. Guid clusterId = Guid.NewGuid(); tenantClient.Setup(c => c.DiscoverTenantsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new( Name: "platform-storage", Namespace: "minio-platform", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") }, CurrentState: "Initialized", Buckets: new List { "cnpg-backups", "loki-logs" }), new( Name: "app-storage", Namespace: "minio-apps", Pools: new List { new(Servers: 2, VolumesPerServer: 4, StorageSize: "50Gi", StorageClass: "standard") }, CurrentState: "Initialized", Buckets: new List { "user-uploads" }) }); AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); AdoptMinioTenantsRequest request = new( ClusterId: clusterId, KubeConfig: "kubeconfig-content", ContextName: "prod-context"); // Act Result> result = await handler.HandleAsync(request); // Assert — Both tenants adopted with correct details. result.IsSuccess.Should().BeTrue(); result.Value.Should().HaveCount(2); IReadOnlyList all = await tenantRepository.GetAllAsync(); all.Should().HaveCount(2); MinioTenant platformTenant = all.First(t => t.Name == "platform-storage"); platformTenant.ClusterId.Should().Be(clusterId); platformTenant.Namespace.Should().Be("minio-platform"); platformTenant.Status.Should().Be(MinioTenantStatus.Running); platformTenant.Pools.Should().HaveCount(1); platformTenant.Pools[0].Servers.Should().Be(4); platformTenant.Pools[0].StorageSize.Should().Be("100Gi"); MinioTenant appTenant = all.First(t => t.Name == "app-storage"); appTenant.Status.Should().Be(MinioTenantStatus.Running); } [Fact] public async Task AdoptTenants_NoTenantsOnCluster_ReturnsEmptyList() { // Arrange — The MinIO operator is installed but no tenants are deployed yet. tenantClient.Setup(c => c.DiscoverTenantsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — Success with empty list, not a failure. result.IsSuccess.Should().BeTrue(); result.Value.Should().BeEmpty(); } [Fact] public async Task AdoptTenants_AlreadyAdopted_DoesNotDuplicate() { // Arrange — One tenant was already adopted. Running adoption again should // skip it and only import new tenants, not create duplicates. Guid clusterId = Guid.NewGuid(); MinioTenant existing = MinioTenant.Adopt( clusterId: clusterId, name: "platform-storage", ns: "minio-platform", pools: new List { new(4, 4, "100Gi", "ceph-block") }); await tenantRepository.AddAsync(existing); tenantClient.Setup(c => c.DiscoverTenantsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new( Name: "platform-storage", Namespace: "minio-platform", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") }, CurrentState: "Initialized", Buckets: new List { "cnpg-backups" }), new( Name: "new-tenant", Namespace: "minio-new", Pools: new List { new(Servers: 2, VolumesPerServer: 2, StorageSize: "50Gi", StorageClass: "standard") }, CurrentState: "Initialized", Buckets: new List()) }); AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); AdoptMinioTenantsRequest request = new(clusterId, "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — Only one new tenant adopted, the existing one was skipped. result.IsSuccess.Should().BeTrue(); result.Value.Should().HaveCount(2); IReadOnlyList all = await tenantRepository.GetAllAsync(); all.Should().HaveCount(2); } [Fact] public async Task AdoptTenants_DegradedTenant_MarkedDegraded() { // Arrange — A tenant with state "Decommissioning" or some non-healthy // state should be imported but marked as Degraded, not Running. tenantClient.Setup(c => c.DiscoverTenantsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new( Name: "sick-tenant", Namespace: "minio-sick", Pools: new List { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") }, CurrentState: "Decommissioning", Buckets: new List()) }); AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); IReadOnlyList all = await tenantRepository.GetAllAsync(); all[0].Status.Should().Be(MinioTenantStatus.Degraded); } // ─── Domain: MinioTenant.Adopt ────────────────────────────────────────────── [Fact] public void Adopt_WithValidConfig_CreatesRunningTenant() { // Arrange — We discover a healthy tenant with known pools. List pools = new() { new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") }; // Act MinioTenant tenant = MinioTenant.Adopt( clusterId: Guid.NewGuid(), name: "discovered-tenant", ns: "minio-discovered", pools: pools); // Assert — Adopted tenants start as Running since they're already serving traffic. tenant.Id.Should().NotBe(Guid.Empty); tenant.Name.Should().Be("discovered-tenant"); tenant.Namespace.Should().Be("minio-discovered"); tenant.Status.Should().Be(MinioTenantStatus.Running); tenant.Pools.Should().HaveCount(1); tenant.Pools[0].Servers.Should().Be(4); } [Fact] public void Adopt_WithEmptyName_Throws() { // A tenant with no name is not valid — discovery must provide a name. Action act = () => MinioTenant.Adopt( Guid.NewGuid(), "", "ns", new List { new(1, 1, "10Gi", "standard") }); act.Should().Throw(); } }