Files
Entkube/tests/EntKube.Provisioning.Tests/Features/MongoClusterFeatureTests.cs
2026-05-13 14:01:32 +02:00

643 lines
24 KiB
C#

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;
/// <summary>
/// Tests for all MongoDB cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class MongoClusterFeatureTests
{
private readonly InMemoryMongoClusterRepository repository;
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly Mock<IMongoClusterClient> mongoClient;
public MongoClusterFeatureTests()
{
repository = new InMemoryMongoClusterRepository();
minioRepository = new InMemoryMinioInstanceRepository();
mongoClient = new Mock<IMongoClusterClient>();
}
// ─── 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<Guid> 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<MongoCluster> 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<MongoClusterSpec>(s =>
s.Name == "platform-mongo" &&
s.MongoVersion == "7.0.12" &&
s.Members == 3 &&
s.MinioEndpoint == "minio.minio-system.svc:9000"),
It.IsAny<CancellationToken>()), 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<Guid> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "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<string> { "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<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both clusters adopted and stored in Running state.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<MongoCluster> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>());
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> 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<string> 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<CancellationToken>()), 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<string> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "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<string> 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<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.12", "7.0.14" });
UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object);
UpgradeMongoClusterRequest request = new(mongoCluster.Id, "99.0", "config", "ctx");
// Act
Result<string> 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<string> 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<MongoClusterSpec>(s => s.Members == 5),
It.IsAny<CancellationToken>()), 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<string> 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<CancellationToken>()), 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<string> 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<string> { "app_db" });
await repository.AddAsync(existing);
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio:9000", "mongo-backups", "minio-creds"),
new("new-mongo", "mongodb-staging", "6.0.17", 1, "20Gi",
new List<string> { "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<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Only the new cluster is adopted, the existing one is updated.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MongoCluster> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("dev-mongo", "default", "7.0.12", 1, "10Gi",
new List<string> { "myapp" },
null, null, null)
});
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Cluster adopted with default MinIO settings.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
IReadOnlyList<MongoCluster> 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<string> { "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<Guid> 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<RestoreFromMongoBackupSpec>(s =>
s.SourceClusterName == "prod-mongo" &&
s.RestoredClusterName == "prod-mongo-restored" &&
s.BackupName == "prod-mongo-dump-20250510120000" &&
s.Namespace == "mongodb-system"),
It.IsAny<CancellationToken>()), Times.Once);
// The restored cluster should be persisted as a new aggregate.
IReadOnlyList<MongoCluster> 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<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
mongoClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromMongoBackupSpec>(s =>
s.TargetTime == targetTime &&
s.RestoredClusterName == "prod-mongo-pitr"),
It.IsAny<CancellationToken>()), 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<Guid> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "app_db" },
"minio:9000", "mongo-backups", "minio-creds")
});
// The live database listing discovers additional databases.
mongoClient.Setup(c => c.ListLiveDatabasesAsync(
It.IsAny<string>(), It.IsAny<string>(),
"prod-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — All three databases should be imported.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MongoCluster> 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");
}
}