using EntKube.Provisioning.Domain; using EntKube.Provisioning.Features.MongoClusters; using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster; using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster; using EntKube.Provisioning.Features.MongoClusters.AddDatabase; using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster; using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster; using EntKube.Provisioning.Features.MongoClusters.RestoreBackup; using EntKube.Provisioning.Infrastructure; using EntKube.SharedKernel.Domain; using FluentAssertions; using Moq; namespace EntKube.Provisioning.Tests.Features; /// /// Tests for all MongoDB cluster management features. Each test verifies that /// the handler correctly orchestrates domain logic + K8s client interactions. /// public class MongoClusterFeatureTests { private readonly InMemoryMongoClusterRepository repository; private readonly InMemoryMinioInstanceRepository minioRepository; private readonly Mock mongoClient; public MongoClusterFeatureTests() { repository = new InMemoryMongoClusterRepository(); minioRepository = new InMemoryMinioInstanceRepository(); mongoClient = new Mock(); } // ─── Create Cluster ─────────────────────────────────────────────────────── [Fact] public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient() { // Arrange — A platform admin wants a new 3-member MongoDB replica set // with mongodump backups to MinIO. CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository); CreateMongoClusterRequest request = new( EnvironmentId: Guid.NewGuid(), ClusterId: Guid.NewGuid(), Name: "platform-mongo", Namespace: "mongodb-system", MongoVersion: "7.0.12", Members: 3, StorageSize: "50Gi", KubeConfig: "apiVersion: v1\nkind: Config", ContextName: "prod-ctx", MinioEndpoint: "minio.minio-system.svc:9000", MinioBucket: "mongo-backups", MinioCredentialsSecret: "minio-mongo-creds"); // Act Result result = await handler.HandleAsync(request); // Assert — Cluster created in repository and K8s client called. result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBe(Guid.Empty); IReadOnlyList clusters = await repository.GetAllAsync(); clusters.Should().HaveCount(1); clusters[0].Name.Should().Be("platform-mongo"); clusters[0].Status.Should().Be(MongoClusterStatus.Provisioning); mongoClient.Verify(c => c.CreateClusterAsync( "apiVersion: v1\nkind: Config", "prod-ctx", It.Is(s => s.Name == "platform-mongo" && s.MongoVersion == "7.0.12" && s.Members == 3 && s.MinioEndpoint == "minio.minio-system.svc:9000"), It.IsAny()), Times.Once); } [Fact] public async Task CreateCluster_WithMissingName_ReturnsFailure() { // Arrange CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository); CreateMongoClusterRequest request = new( EnvironmentId: Guid.NewGuid(), ClusterId: Guid.NewGuid(), Name: "", Namespace: "ns", MongoVersion: "7.0.12", Members: 3, StorageSize: "50Gi", KubeConfig: "config", ContextName: "ctx", MinioEndpoint: "minio:9000", MinioBucket: "bucket", MinioCredentialsSecret: "secret"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("name"); } // ─── Adopt Cluster ──────────────────────────────────────────────────────── [Fact] public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll() { // Arrange — The K8s cluster has 2 existing MongoDBCommunity clusters. mongoClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", new List { "app_db", "analytics_db" }, "minio.minio-system.svc:9000", "mongo-backups", "minio-creds"), new("staging-mongo", "mongodb-staging", "6.0.16", 1, "20Gi", new List { "staging_db" }, "minio.minio-system.svc:9000", "mongo-backups-staging", "minio-creds") }); AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); AdoptMongoClustersRequest request = new( EnvironmentId: Guid.NewGuid(), ClusterId: Guid.NewGuid(), KubeConfig: "config", ContextName: "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — Both clusters adopted and stored in Running state. result.IsSuccess.Should().BeTrue(); result.Value.Should().HaveCount(2); IReadOnlyList all = await repository.GetAllAsync(); all.Should().HaveCount(2); all[0].Status.Should().Be(MongoClusterStatus.Running); all[0].Databases.Should().Contain("app_db"); all[1].Databases.Should().Contain("staging_db"); } [Fact] public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList() { // Arrange — No MongoDBCommunity clusters found on K8s. mongoClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().BeEmpty(); } // ─── Add Database ───────────────────────────────────────────────────────── [Fact] public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s() { // Arrange — A running MongoDB cluster exists. MongoCluster mongoCluster = MongoCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret"); mongoCluster.MarkRunning(); await repository.AddAsync(mongoCluster); AddMongoDatabaseHandler handler = new(repository, mongoClient.Object); AddMongoDatabaseRequest request = new( MongoClusterId: mongoCluster.Id, DatabaseName: "new_app_db", OwnerRole: "app_user", KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); updated!.Databases.Should().Contain("new_app_db"); mongoClient.Verify(c => c.CreateDatabaseAsync( "config", "ctx", "prod-mongo", "mongodb-system", "new_app_db", "app_user", It.IsAny()), Times.Once); } [Fact] public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure() { // Arrange AddMongoDatabaseHandler handler = new(repository, mongoClient.Object); AddMongoDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not found"); } // ─── Upgrade Cluster ────────────────────────────────────────────────────── [Fact] public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s() { // Arrange — A running cluster at version 6.0.16. MongoCluster mongoCluster = MongoCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "6.0.16", 3, "50Gi", "minio:9000", "bucket", "secret"); mongoCluster.MarkRunning(); await repository.AddAsync(mongoCluster); mongoClient.Setup(c => c.GetAvailableVersionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { "6.0.16", "6.0.17", "7.0.12", "7.0.14" }); UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object); UpgradeMongoClusterRequest request = new( MongoClusterId: mongoCluster.Id, TargetVersion: "7.0.12", KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert — Domain transitions to Upgrading, K8s client called. result.IsSuccess.Should().BeTrue(); MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); updated!.Status.Should().Be(MongoClusterStatus.Upgrading); updated.TargetVersion.Should().Be("7.0.12"); mongoClient.Verify(c => c.UpgradeClusterAsync( "config", "ctx", "prod-mongo", "mongodb-system", "7.0.12", It.IsAny()), Times.Once); } [Fact] public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure() { // Arrange — Version 99.0 doesn't exist. MongoCluster mongoCluster = MongoCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret"); mongoCluster.MarkRunning(); await repository.AddAsync(mongoCluster); mongoClient.Setup(c => c.GetAvailableVersionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { "7.0.12", "7.0.14" }); UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object); UpgradeMongoClusterRequest request = new(mongoCluster.Id, "99.0", "config", "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not available"); } // ─── Configure Cluster ──────────────────────────────────────────────────── [Fact] public async Task ConfigureCluster_ScaleMembers_UpdatesK8sAndDomain() { // Arrange — Scale a running cluster from 3 to 5 members. MongoCluster mongoCluster = MongoCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret"); mongoCluster.MarkRunning(); await repository.AddAsync(mongoCluster); ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); ConfigureMongoClusterRequest request = new( MongoClusterId: mongoCluster.Id, Members: 5, StorageSize: null, BackupSchedule: null, BackupRetentionDays: null, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); updated!.Members.Should().Be(5); mongoClient.Verify(c => c.UpdateClusterAsync( "config", "ctx", It.Is(s => s.Members == 5), It.IsAny()), Times.Once); } [Fact] public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesBackupCronJob() { // Arrange — Change backup schedule to every 6 hours with 30-day retention. MongoCluster mongoCluster = MongoCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret"); mongoCluster.MarkRunning(); await repository.AddAsync(mongoCluster); ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); ConfigureMongoClusterRequest request = new( MongoClusterId: mongoCluster.Id, Members: null, StorageSize: null, BackupSchedule: "0 */6 * * *", BackupRetentionDays: 30, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); updated.BackupConfig.RetentionDays.Should().Be(30); mongoClient.Verify(c => c.ConfigureScheduledBackupAsync( "config", "ctx", "prod-mongo", "mongodb-system", "0 */6 * * *", 30, It.IsAny()), Times.Once); } [Fact] public async Task ConfigureCluster_NonExistent_ReturnsFailure() { // Arrange ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); ConfigureMongoClusterRequest request = new( Guid.NewGuid(), Members: 5, StorageSize: null, BackupSchedule: null, BackupRetentionDays: null, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not found"); } // ─── Adopt De-duplication ───────────────────────────────────────────────── [Fact] public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate() { // Arrange — The platform already has a cluster adopted. Re-running // discovery should not create a duplicate but update databases. Guid clusterId = Guid.NewGuid(); MongoCluster existing = MongoCluster.Adopt( Guid.NewGuid(), clusterId, "prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", "minio:9000", "mongo-backups", "minio-creds", databases: new List { "app_db" }); await repository.AddAsync(existing); mongoClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", new List { "app_db", "analytics_db" }, "minio:9000", "mongo-backups", "minio-creds"), new("new-mongo", "mongodb-staging", "6.0.17", 1, "20Gi", new List { "staging_db" }, "minio:9000", "mongo-backups-staging", "minio-creds") }); AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); AdoptMongoClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — Only the new cluster is adopted, the existing one is updated. result.IsSuccess.Should().BeTrue(); IReadOnlyList all = await repository.GetByClusterIdAsync(clusterId); all.Should().HaveCount(2); all.Should().ContainSingle(c => c.Name == "prod-mongo"); all.Should().ContainSingle(c => c.Name == "new-mongo"); // The existing cluster's databases should be updated. MongoCluster? updated = all.First(c => c.Name == "prod-mongo"); updated.Databases.Should().Contain("analytics_db"); } [Fact] public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults() { // Arrange — A MongoDBCommunity cluster with no MinIO backup configured. mongoClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("dev-mongo", "default", "7.0.12", 1, "10Gi", new List { "myapp" }, null, null, null) }); AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — Cluster adopted with default MinIO settings. result.IsSuccess.Should().BeTrue(); result.Value.Should().HaveCount(1); IReadOnlyList all = await repository.GetAllAsync(); all[0].Name.Should().Be("dev-mongo"); all[0].Status.Should().Be(MongoClusterStatus.Running); } // ─── Restore Backup ─────────────────────────────────────────────────────── [Fact] public async Task RestoreBackup_WithValidCluster_CallsMongoClient() { // Arrange — A running cluster with a backup we want to restore from. MongoCluster mongoCluster = MongoCluster.Adopt( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret", databases: new List { "app_db" }); await repository.AddAsync(mongoCluster); RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); RestoreMongoBackupRequest request = new( MongoClusterId: mongoCluster.Id, BackupName: "prod-mongo-dump-20250510120000", RestoredClusterName: "prod-mongo-restored", KubeConfig: "config", ContextName: "ctx", TargetTime: null); // Act Result result = await handler.HandleAsync(request); // Assert — A new cluster should be created from the backup. result.IsSuccess.Should().BeTrue(); mongoClient.Verify(c => c.RestoreBackupAsync( "config", "ctx", It.Is(s => s.SourceClusterName == "prod-mongo" && s.RestoredClusterName == "prod-mongo-restored" && s.BackupName == "prod-mongo-dump-20250510120000" && s.Namespace == "mongodb-system"), It.IsAny()), Times.Once); // The restored cluster should be persisted as a new aggregate. IReadOnlyList all = await repository.GetAllAsync(); all.Should().HaveCount(2); all.Should().ContainSingle(c => c.Name == "prod-mongo-restored"); } [Fact] public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime() { // Arrange — Restore to a specific point in time using oplog replay. MongoCluster mongoCluster = MongoCluster.Adopt( Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", "minio:9000", "bucket", "secret"); await repository.AddAsync(mongoCluster); RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero); RestoreMongoBackupRequest request = new( MongoClusterId: mongoCluster.Id, BackupName: null, RestoredClusterName: "prod-mongo-pitr", KubeConfig: "config", ContextName: "ctx", TargetTime: targetTime); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); mongoClient.Verify(c => c.RestoreBackupAsync( "config", "ctx", It.Is(s => s.TargetTime == targetTime && s.RestoredClusterName == "prod-mongo-pitr"), It.IsAny()), Times.Once); } [Fact] public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure() { // Arrange RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); RestoreMongoBackupRequest request = new( Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null); // Act Result result = await handler.HandleAsync(request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("not found"); } // ─── List Live Databases ────────────────────────────────────────────────── [Fact] public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases() { // Arrange — Discovery from the CRD spec only finds configured databases, // but the live MongoDB has additional databases created via shell. mongoClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", new List { "app_db" }, "minio:9000", "mongo-backups", "minio-creds") }); // The live database listing discovers additional databases. mongoClient.Setup(c => c.ListLiveDatabasesAsync( It.IsAny(), It.IsAny(), "prod-mongo", "mongodb-system", It.IsAny())) .ReturnsAsync(new List { "app_db", "analytics_db", "reporting_db" }); AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — All three databases should be imported. result.IsSuccess.Should().BeTrue(); IReadOnlyList all = await repository.GetAllAsync(); all[0].Databases.Should().HaveCount(3); all[0].Databases.Should().Contain("app_db"); all[0].Databases.Should().Contain("analytics_db"); all[0].Databases.Should().Contain("reporting_db"); } }