using EntKube.Provisioning.Domain; using EntKube.Provisioning.Features.PostgresClusters; using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster; using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; using EntKube.Provisioning.Features.PostgresClusters.AddDatabase; using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster; using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster; using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup; using EntKube.Provisioning.Infrastructure; using EntKube.SharedKernel.Domain; using FluentAssertions; using Moq; namespace EntKube.Provisioning.Tests.Features; /// /// Tests for all PostgreSQL cluster management features. Each test verifies that /// the handler correctly orchestrates domain logic + K8s client interactions. /// public class PostgresClusterFeatureTests { private readonly InMemoryPostgresClusterRepository repository; private readonly InMemoryMinioInstanceRepository minioRepository; private readonly Mock cnpgClient; public PostgresClusterFeatureTests() { repository = new InMemoryPostgresClusterRepository(); minioRepository = new InMemoryMinioInstanceRepository(); cnpgClient = new Mock(); } // ─── Create Cluster ─────────────────────────────────────────────────────── [Fact] public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient() { // Arrange — A platform admin wants a new 3-instance PostgreSQL cluster // with WAL archiving to MinIO. CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository); CreatePostgresClusterRequest request = new( EnvironmentId: Guid.NewGuid(), ClusterId: Guid.NewGuid(), Name: "platform-db", Namespace: "cnpg-system", PostgresVersion: "16.4", Instances: 3, StorageSize: "50Gi", KubeConfig: "apiVersion: v1\nkind: Config", ContextName: "prod-ctx", MinioEndpoint: "minio.minio-system.svc:9000", MinioBucket: "cnpg-backups", MinioCredentialsSecret: "minio-cnpg-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-db"); clusters[0].Status.Should().Be(PostgresClusterStatus.Provisioning); cnpgClient.Verify(c => c.CreateClusterAsync( "apiVersion: v1\nkind: Config", "prod-ctx", It.Is(s => s.Name == "platform-db" && s.PostgresVersion == "16.4" && s.Instances == 3 && s.MinioEndpoint == "minio.minio-system.svc:9000"), It.IsAny()), Times.Once); } [Fact] public async Task CreateCluster_WithMissingName_ReturnsFailure() { // Arrange CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository); CreatePostgresClusterRequest request = new( EnvironmentId: Guid.NewGuid(), ClusterId: Guid.NewGuid(), Name: "", Namespace: "ns", PostgresVersion: "16.4", Instances: 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 CNPG clusters. We adopt them // so the platform is aware of what's already running. cnpgClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-db", "cnpg-system", "16.4", 3, "100Gi", new List { "app_db", "analytics_db" }, "minio.minio-system.svc:9000", "pg-backups", "minio-creds", true), new("staging-db", "cnpg-staging", "15.6", 1, "20Gi", new List { "staging_db" }, "minio.minio-system.svc:9000", "pg-backups-staging", "minio-creds", true) }); AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); AdoptPostgresClustersRequest 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(PostgresClusterStatus.Running); all[0].Databases.Should().Contain("app_db"); all[1].Databases.Should().Contain("staging_db"); } [Fact] public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList() { // Arrange — No CNPG clusters found on the K8s cluster. cnpgClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); AdoptPostgresClustersRequest 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 PostgreSQL cluster exists. PostgresCluster pgCluster = PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); await repository.AddAsync(pgCluster); AddDatabaseHandler handler = new(repository, cnpgClient.Object); AddDatabaseRequest request = new( PostgresClusterId: pgCluster.Id, DatabaseName: "new_app_db", OwnerRole: "app_user", KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); updated!.Databases.Should().Contain("new_app_db"); cnpgClient.Verify(c => c.CreateDatabaseAsync( "config", "ctx", "prod-db", "cnpg-system", "new_app_db", "app_user", It.IsAny()), Times.Once); } [Fact] public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure() { // Arrange AddDatabaseHandler handler = new(repository, cnpgClient.Object); AddDatabaseRequest 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 15.6. PostgresCluster pgCluster = PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "15.6", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); await repository.AddAsync(pgCluster); cnpgClient.Setup(c => c.GetAvailableVersionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { "15.6", "15.7", "16.4", "16.5" }); UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object); UpgradePostgresClusterRequest request = new( PostgresClusterId: pgCluster.Id, TargetVersion: "16.4", KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert — Domain transitions to Upgrading, K8s client called. result.IsSuccess.Should().BeTrue(); PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); updated!.Status.Should().Be(PostgresClusterStatus.Upgrading); updated.TargetVersion.Should().Be("16.4"); cnpgClient.Verify(c => c.UpgradeClusterAsync( "config", "ctx", "prod-db", "cnpg-system", "16.4", It.IsAny()), Times.Once); } [Fact] public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure() { // Arrange — Version 99.0 doesn't exist. PostgresCluster pgCluster = PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); await repository.AddAsync(pgCluster); cnpgClient.Setup(c => c.GetAvailableVersionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { "16.4", "16.5" }); UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object); UpgradePostgresClusterRequest request = new(pgCluster.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_ScaleInstances_UpdatesK8sAndDomain() { // Arrange — Scale a running cluster from 3 to 5 instances. PostgresCluster pgCluster = PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); await repository.AddAsync(pgCluster); ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); ConfigurePostgresClusterRequest request = new( PostgresClusterId: pgCluster.Id, Instances: 5, StorageSize: null, BackupSchedule: null, BackupRetentionDays: null, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); updated!.Instances.Should().Be(5); cnpgClient.Verify(c => c.UpdateClusterAsync( "config", "ctx", It.Is(s => s.Instances == 5), It.IsAny()), Times.Once); } [Fact] public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesScheduledBackup() { // Arrange — Change backup schedule to every 6 hours with 30-day retention. PostgresCluster pgCluster = PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); await repository.AddAsync(pgCluster); ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); ConfigurePostgresClusterRequest request = new( PostgresClusterId: pgCluster.Id, Instances: null, StorageSize: null, BackupSchedule: "0 */6 * * *", BackupRetentionDays: 30, KubeConfig: "config", ContextName: "ctx"); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); updated.BackupConfig.RetentionDays.Should().Be(30); cnpgClient.Verify(c => c.ConfigureScheduledBackupAsync( "config", "ctx", "prod-db", "cnpg-system", "0 */6 * * *", 30, It.IsAny()), Times.Once); } [Fact] public async Task ConfigureCluster_NonExistent_ReturnsFailure() { // Arrange ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); ConfigurePostgresClusterRequest request = new( Guid.NewGuid(), Instances: 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 with the same name and // namespace adopted. Re-running discovery should not create a duplicate. Guid clusterId = Guid.NewGuid(); PostgresCluster existing = PostgresCluster.Adopt( Guid.NewGuid(), clusterId, "prod-db", "cnpg-system", "16.4", 3, "100Gi", "minio:9000", "pg-backups", "minio-creds", databases: new List { "app_db" }); await repository.AddAsync(existing); cnpgClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-db", "cnpg-system", "16.4", 3, "100Gi", new List { "app_db", "analytics_db" }, "minio:9000", "pg-backups", "minio-creds", true), new("new-db", "cnpg-staging", "15.8", 1, "20Gi", new List { "staging_db" }, "minio:9000", "pg-backups-staging", "minio-creds", true) }); AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); AdoptPostgresClustersRequest 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-db"); all.Should().ContainSingle(c => c.Name == "new-db"); // The existing cluster's databases should be updated with newly discovered ones. PostgresCluster? updated = all.First(c => c.Name == "prod-db"); updated.Databases.Should().Contain("analytics_db"); } [Fact] public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults() { // Arrange — A CNPG cluster that has no MinIO backup configured. // The platform should still adopt it with default backup settings. cnpgClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("dev-db", "default", "16.4", 1, "10Gi", new List { "myapp" }, null, null, null, false) }); AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); AdoptPostgresClustersRequest 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-db"); all[0].Status.Should().Be(PostgresClusterStatus.Running); } // ─── Restore Backup ─────────────────────────────────────────────────────── [Fact] public async Task RestoreBackup_WithValidCluster_CallsCnpgClient() { // Arrange — A running cluster with a backup we want to restore from. // Restoring in CNPG means creating a new cluster from a recovery source. PostgresCluster pgCluster = PostgresCluster.Adopt( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret", databases: new List { "app_db" }); await repository.AddAsync(pgCluster); RestoreBackupHandler handler = new(repository, cnpgClient.Object); RestoreBackupRequest request = new( PostgresClusterId: pgCluster.Id, BackupName: "prod-db-manual-20250510120000", RestoredClusterName: "prod-db-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(); cnpgClient.Verify(c => c.RestoreBackupAsync( "config", "ctx", It.Is(s => s.SourceClusterName == "prod-db" && s.RestoredClusterName == "prod-db-restored" && s.BackupName == "prod-db-manual-20250510120000" && s.Namespace == "cnpg-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-db-restored"); } [Fact] public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime() { // Arrange — Restore to a specific point in time (PITR). PostgresCluster pgCluster = PostgresCluster.Adopt( Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); await repository.AddAsync(pgCluster); RestoreBackupHandler handler = new(repository, cnpgClient.Object); DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero); RestoreBackupRequest request = new( PostgresClusterId: pgCluster.Id, BackupName: null, RestoredClusterName: "prod-db-pitr", KubeConfig: "config", ContextName: "ctx", TargetTime: targetTime); // Act Result result = await handler.HandleAsync(request); // Assert result.IsSuccess.Should().BeTrue(); cnpgClient.Verify(c => c.RestoreBackupAsync( "config", "ctx", It.Is(s => s.TargetTime == targetTime && s.RestoredClusterName == "prod-db-pitr"), It.IsAny()), Times.Once); } [Fact] public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure() { // Arrange RestoreBackupHandler handler = new(repository, cnpgClient.Object); RestoreBackupRequest 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 CNPG CR spec only finds the bootstrap // database, but the live cluster has additional databases created via SQL. // The adoption should include all of them. cnpgClient.Setup(c => c.DiscoverClustersAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new("prod-db", "cnpg-system", "16.4", 3, "100Gi", new List { "app_db" }, "minio:9000", "pg-backups", "minio-creds", true) }); // The live database listing discovers additional databases. cnpgClient.Setup(c => c.ListLiveDatabasesAsync( It.IsAny(), It.IsAny(), "prod-db", "cnpg-system", It.IsAny())) .ReturnsAsync(new List { "app_db", "analytics_db", "reporting_db" }); AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); // Act Result> result = await handler.HandleAsync(request); // Assert — All databases from the live cluster are included. 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"); } }