using EntKube.SharedKernel.Domain; using FluentAssertions; namespace EntKube.Provisioning.Tests.Domain; /// /// Tests for the PostgresCluster aggregate root. This rich domain model tracks /// everything about a CloudNativePG PostgreSQL cluster: instances, storage, /// WAL archiving, backup configuration, databases, and version lifecycle. /// public class PostgresClusterTests { [Fact] public void Create_WithValidInputs_ReturnsClusterInProvisioningState() { // Arrange & Act — A platform admin provisions a new PostgreSQL cluster // for shared use on a specific Kubernetes cluster. Guid environmentId = Guid.NewGuid(); Guid clusterId = Guid.NewGuid(); Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( environmentId: environmentId, clusterId: clusterId, name: "platform-db", ns: "cnpg-system", postgresVersion: "16.4", instances: 3, storageSize: "50Gi", minioEndpoint: "minio.minio-system.svc:9000", minioBucket: "cnpg-backups", minioCredentialsSecret: "minio-cnpg-credentials"); // Assert — The cluster starts in Provisioning state, waiting for the // reconciler to actually create the CNPG Cluster resource. pgCluster.Id.Should().NotBe(Guid.Empty); pgCluster.EnvironmentId.Should().Be(environmentId); pgCluster.ClusterId.Should().Be(clusterId); pgCluster.Name.Should().Be("platform-db"); pgCluster.Namespace.Should().Be("cnpg-system"); pgCluster.PostgresVersion.Should().Be("16.4"); pgCluster.Instances.Should().Be(3); pgCluster.StorageSize.Should().Be("50Gi"); pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Provisioning); pgCluster.Databases.Should().BeEmpty(); } [Fact] public void Create_WithMinIOBackupConfig_SetsWalArchiving() { // Arrange & Act — WAL archiving to MinIO is configured at creation time // since it's a hard dependency. The cluster won't be created without it. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( environmentId: Guid.NewGuid(), clusterId: Guid.NewGuid(), name: "prod-db", ns: "cnpg-prod", postgresVersion: "16.4", instances: 3, storageSize: "100Gi", minioEndpoint: "minio.minio-system.svc:9000", minioBucket: "pg-wal-archive", minioCredentialsSecret: "minio-pg-creds"); // Assert — Backup configuration is stored as part of the aggregate. pgCluster.BackupConfig.Should().NotBeNull(); pgCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000"); pgCluster.BackupConfig.Bucket.Should().Be("pg-wal-archive"); pgCluster.BackupConfig.CredentialsSecret.Should().Be("minio-pg-creds"); pgCluster.BackupConfig.WalArchivingEnabled.Should().BeTrue(); } [Fact] public void Create_WithEmptyName_ThrowsArgumentException() { // Arrange & Act — The domain rejects invalid cluster names. Action act = () => Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "", "ns", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); // Assert act.Should().Throw().WithParameterName("name"); } [Fact] public void Create_WithZeroInstances_ThrowsArgumentException() { // Arrange & Act — Must have at least 1 instance. Action act = () => Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 0, "50Gi", "minio:9000", "bucket", "secret"); // Assert act.Should().Throw().WithParameterName("instances"); } [Fact] public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup() { // Arrange & Act — Backup is optional. When MinIO endpoint is empty, // the cluster should be created successfully without backup config. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi", "", "bucket", "secret"); // Assert — cluster exists but has no backup configuration. pgCluster.Should().NotBeNull(); pgCluster.BackupConfig.Should().BeNull(); } [Fact] public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig() { // Arrange & Act — When we adopt an existing CNPG cluster that's already // running on the K8s cluster, we create it in Running state with the // discovered configuration. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Adopt( environmentId: Guid.NewGuid(), clusterId: Guid.NewGuid(), name: "existing-db", ns: "cnpg-system", postgresVersion: "15.6", instances: 2, storageSize: "20Gi", minioEndpoint: "minio.minio-system.svc:9000", minioBucket: "existing-backups", minioCredentialsSecret: "existing-minio-creds", databases: new List { "app_db", "analytics_db" }); // Assert — Adopted clusters start in Running state and include // any databases that were already present. pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); pgCluster.Databases.Should().HaveCount(2); pgCluster.Databases.Should().Contain("app_db"); pgCluster.Databases.Should().Contain("analytics_db"); } [Fact] public void MarkRunning_TransitionsFromProvisioning() { // Arrange — A freshly created cluster in Provisioning state. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); // Act — The reconciler confirmed the cluster is ready. pgCluster.MarkRunning(); // Assert pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); pgCluster.LastReconcileAt.Should().NotBeNull(); } [Fact] public void AddDatabase_WhenRunning_AddsDatabaseToList() { // Arrange — A running cluster can have databases added to it. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); // Act — An application requests a new database be provisioned. Result result = pgCluster.AddDatabase("my_app_db", "app_user"); // Assert result.IsSuccess.Should().BeTrue(); pgCluster.Databases.Should().Contain("my_app_db"); } [Fact] public void AddDatabase_WhenNotRunning_ReturnsFailure() { // Arrange — A cluster that's still provisioning can't accept new databases. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); // Act Result result = pgCluster.AddDatabase("my_db", "user"); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("running"); } [Fact] public void AddDatabase_DuplicateName_ReturnsFailure() { // Arrange — A running cluster with one database already. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); pgCluster.AddDatabase("existing_db", "user1"); // Act — Try to add a database with the same name. Result result = pgCluster.AddDatabase("existing_db", "user2"); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("already exists"); } [Fact] public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion() { // Arrange — A running cluster at version 15.6. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); // Act — Platform admin requests upgrade to 16.4. Result result = pgCluster.RequestUpgrade("16.4"); // Assert — The cluster transitions to Upgrading state with the target version recorded. result.IsSuccess.Should().BeTrue(); pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Upgrading); pgCluster.TargetVersion.Should().Be("16.4"); } [Fact] public void RequestUpgrade_WhenNotRunning_ReturnsFailure() { // Arrange — A cluster still provisioning can't be upgraded. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); // Act Result result = pgCluster.RequestUpgrade("16.4"); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("running"); } [Fact] public void RequestUpgrade_ToSameVersion_ReturnsFailure() { // Arrange — A cluster already at version 16.4. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); // Act — Try to upgrade to the same version. Result result = pgCluster.RequestUpgrade("16.4"); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("same version"); } [Fact] public void CompleteUpgrade_TransitionsToRunningWithNewVersion() { // Arrange — A cluster that's mid-upgrade. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi", "minio:9000", "bucket", "secret"); pgCluster.MarkRunning(); pgCluster.RequestUpgrade("16.4"); // Act — The reconciler confirmed the upgrade completed. pgCluster.CompleteUpgrade(); // Assert — Version updated, status back to Running. pgCluster.PostgresVersion.Should().Be("16.4"); pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); pgCluster.TargetVersion.Should().BeNull(); } [Fact] public void ScaleInstances_UpdatesInstanceCount() { // Arrange — A running 3-instance cluster. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); // Act — Scale up to 5 instances for more read replicas. Result result = pgCluster.ScaleInstances(5); // Assert result.IsSuccess.Should().BeTrue(); pgCluster.Instances.Should().Be(5); } [Fact] public void ScaleInstances_ToZero_ReturnsFailure() { // Arrange Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); // Act Result result = pgCluster.ScaleInstances(0); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("at least 1"); } [Fact] public void ConfigureBackupSchedule_UpdatesRetentionPolicy() { // Arrange — A running cluster needs its backup schedule updated. Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); // Act — Set backup to run every 6 hours with 30-day retention. pgCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30); // Assert pgCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); pgCluster.BackupConfig.RetentionDays.Should().Be(30); } [Fact] public void MarkDegraded_SetsStatusToDegraded() { // Arrange Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); pgCluster.MarkRunning(); // Act — Reconciler detected an issue. pgCluster.MarkDegraded("Primary pod not ready"); // Assert pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Degraded); pgCluster.StatusMessage.Should().Be("Primary pod not ready"); } [Fact] public void Create_WithS3Region_StoresRegionInBackupConfig() { // Arrange & Act — When creating a CNPG cluster backed by an external S3 // provider like Cleura, the region must be captured so barman-cloud can // use it for AWS Signature V4 authentication. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( environmentId: Guid.NewGuid(), clusterId: Guid.NewGuid(), name: "cleura-db", ns: "cnpg-prod", postgresVersion: "16.4", instances: 3, storageSize: "100Gi", minioEndpoint: "https://s3.cleura.cloud", minioBucket: "cnpg-backups", minioCredentialsSecret: "cnpg-s3-creds", s3Region: "eu-north-1"); // Assert — The region is stored in the backup configuration so it can // be included in the CNPG manifest's s3Credentials.region field. pgCluster.BackupConfig.Should().NotBeNull(); pgCluster.BackupConfig!.S3Region.Should().Be("eu-north-1"); } [Fact] public void Create_WithoutS3Region_DefaultsToNull() { // Arrange & Act — MinIO endpoints don't need a region. When omitted, // S3Region should be null and barman-cloud will skip region config. Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( environmentId: Guid.NewGuid(), clusterId: Guid.NewGuid(), name: "minio-db", ns: "cnpg-system", postgresVersion: "16.4", instances: 3, storageSize: "50Gi", minioEndpoint: "minio.minio-system.svc:9000", minioBucket: "cnpg-backups", minioCredentialsSecret: "minio-creds"); // Assert pgCluster.BackupConfig.Should().NotBeNull(); pgCluster.BackupConfig!.S3Region.Should().BeNull(); } private static Provisioning.Domain.PostgresCluster CreateTestCluster() { return Provisioning.Domain.PostgresCluster.Create( Guid.NewGuid(), Guid.NewGuid(), "test-db", "cnpg-system", "16.4", 3, "50Gi", "minio.minio-system.svc:9000", "cnpg-backups", "minio-cnpg-credentials"); } }