too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,98 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.AddAppEnvironment;
using EntKube.Provisioning.Features.Apps.Reconcile;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
public class AddAppEnvironmentHandlerTests
{
private readonly AddAppEnvironmentHandler handler;
private readonly InMemoryAppRepository repository;
private readonly Mock<IKubernetesApplyClient> applyClientMock;
public AddAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
applyClientMock = new Mock<IKubernetesApplyClient>();
handler = new AddAppEnvironmentHandler(repository, applyClientMock.Object);
}
[Fact]
public async Task HandleAsync_WithValidRequest_AddsEnvironmentToApp()
{
// Arrange — An app exists and the admin wants to add it to the dev environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AddAppEnvironmentRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
ClusterId: clusterId,
Namespace: "api-dev");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The environment is added and its ID returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments.Should().HaveCount(1);
updated.Environments[0].EnvironmentId.Should().Be(environmentId);
updated.Environments[0].ClusterId.Should().Be(clusterId);
updated.Environments[0].Namespace.Should().Be("api-dev");
// Verify that a namespace manifest was applied to the cluster.
applyClientMock.Verify(
c => c.ApplyManifestAsync(clusterId, It.Is<string>(y => y.Contains("kind: Namespace") && y.Contains("name: api-dev")), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithNonExistentApp_ReturnsFailure()
{
// Arrange & Act
AddAppEnvironmentRequest request = new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "ns");
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDuplicateEnvironment_ReturnsFailure()
{
// Arrange — The app is already deployed to this environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
AddAppEnvironmentRequest request = new(app.Id, environmentId, Guid.NewGuid(), "api-dev-2");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already");
}
}

View File

@@ -0,0 +1,88 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.AddAppSecret;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class AddAppSecretHandlerTests
{
private readonly AddAppSecretHandler handler;
private readonly InMemoryAppRepository repository;
public AddAppSecretHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new AddAppSecretHandler(repository);
}
[Fact]
public async Task HandleAsync_WithValidRequest_AddsSecretToEnvironment()
{
// Arrange — An app deployed to dev needs a database connection string.
// The actual secret value lives in the vault — we just store the reference.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
AddAppSecretRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Name: "DATABASE_URL",
VaultKey: "database-connection-string");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.Secrets.Should().HaveCount(1);
env.Secrets[0].Name.Should().Be("DATABASE_URL");
env.Secrets[0].VaultKey.Should().Be("database-connection-string");
}
[Fact]
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
{
// Arrange & Act
AddAppSecretRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "KEY", "vault-key");
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_DuplicateSecretName_ReturnsFailure()
{
// Arrange — A secret with this name already exists.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn");
await repository.AddAsync(app);
AddAppSecretRequest request = new(app.Id, environmentId, "DATABASE_URL", "other-key");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already exists");
}
}

View File

@@ -0,0 +1,387 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.Reconcile;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
public class AppReconcilerTests
{
private readonly InMemoryAppRepository repository;
private readonly Mock<IKubernetesApplyClient> applyClient;
private readonly Mock<ISecretResolver> secretResolver;
private readonly Mock<IClusterGatewayResolver> gatewayResolver;
private readonly AppReconcileHandler handler;
public AppReconcilerTests()
{
repository = new InMemoryAppRepository();
applyClient = new Mock<IKubernetesApplyClient>();
secretResolver = new Mock<ISecretResolver>();
gatewayResolver = new Mock<IClusterGatewayResolver>();
handler = new AppReconcileHandler(repository, applyClient.Object, secretResolver.Object, gatewayResolver.Object);
}
// ─── Deployment-type app reconciliation ──────────────────────────────
[Fact]
public async Task ReconcileAsync_DeploymentApp_AppliesAllManifests()
{
// Arrange — A Deployment-type app with a configured environment.
// The reconciler should generate and apply deployment.yaml, service.yaml,
// and httproute.yaml to the target cluster.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 2,
ContainerPort: 8080,
ServicePort: 80,
HostName: "api.dev.example.com",
PathPrefix: "/",
EnvironmentVariables: null,
Resources: null));
await repository.AddAsync(app);
// Act — Reconcile all pending environments.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Three manifests should be applied: Deployment, Service, HTTPRoute.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Deployment")),
It.IsAny<CancellationToken>()), Times.Once);
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Service")),
It.IsAny<CancellationToken>()), Times.Once);
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: HTTPRoute")),
It.IsAny<CancellationToken>()), Times.Once);
// The environment should be marked as Synced after successful reconciliation.
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
}
[Fact]
public async Task ReconcileAsync_DeploymentAppWithSecrets_ResolvesAndAppliesSecretManifest()
{
// Arrange — An app with a secret reference. The reconciler should
// resolve the vault key, generate a Secret manifest, and apply it.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.AddSecret("DATABASE_URL", "db-connection-string");
await repository.AddAsync(app);
secretResolver.Setup(r => r.ResolveAsync(
tenantId, customerId, "api", envId, "db-connection-string",
It.IsAny<CancellationToken>()))
.ReturnsAsync("postgresql://db:5432/mydb");
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — A Secret manifest should be applied alongside the Deployment.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Secret")),
It.IsAny<CancellationToken>()), Times.Once);
// The Deployment manifest should include secretKeyRef for the secret.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("secretKeyRef:")),
It.IsAny<CancellationToken>()), Times.Once);
}
// ─── HelmChart-type app reconciliation ───────────────────────────────
[Fact]
public async Task ReconcileAsync_HelmChartApp_CallsHelmUpgradeInstall()
{
// Arrange — A HelmChart-type app. The reconciler should call
// HelmUpgradeInstallAsync instead of applying YAML manifests.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "Redis", "redis", AppType.HelmChart);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "redis-dev");
HelmReleaseSpec helmSpec = new(
RepoUrl: "https://charts.bitnami.com/bitnami",
ChartName: "redis",
ChartVersion: "18.6.1",
ValuesYaml: "replica:\n replicaCount: 3");
env.ConfigureHelmRelease(helmSpec);
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Should call helm upgrade install, not apply manifests.
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
clusterId,
"redis",
"redis-dev",
It.Is<HelmReleaseSpec>(s => s.ChartName == "redis" && s.ChartVersion == "18.6.1"),
It.IsAny<CancellationToken>()), Times.Once);
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
}
// ─── Error handling ──────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_WhenApplyFails_MarksEnvironmentAsError()
{
// Arrange — The K8s apply call throws an exception.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
await repository.AddAsync(app);
applyClient.Setup(c => c.ApplyManifestAsync(
clusterId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Cluster unreachable"));
// Act — Reconcile should not throw — it catches and marks error.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The environment should be marked with an error.
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Error);
updated.Environments[0].SyncStatusMessage.Should().Contain("Cluster unreachable");
}
// ─── Skipping already-synced environments ────────────────────────────
[Fact]
public async Task ReconcileAsync_AlreadySynced_SkipsReconciliation()
{
// Arrange — An environment that's already synced shouldn't be reprocessed.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkSynced();
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — No K8s calls should be made.
applyClient.Verify(c => c.ApplyManifestAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<HelmReleaseSpec>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ─── Suspended apps ──────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_SuspendedApp_SkipsReconciliation()
{
// Arrange — A suspended app should not be reconciled even if
// environments are pending.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
app.Suspend();
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert
applyClient.Verify(c => c.ApplyManifestAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ─── Gateway auto-resolution ─────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_RouteWithoutGateway_InjectsClusterDefaultGateway()
{
// Arrange — An app with a route that has no GatewayRef specified.
// The reconciler should ask the cluster for its default gateway and
// inject it into the route manifest automatically.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "Web", "web", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "web-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/web",
Tag: "v1.0.0",
Replicas: 1,
ContainerPort: 8080,
ServicePort: 80,
HostName: null,
PathPrefix: null,
EnvironmentVariables: null,
Resources: null,
Services: new List<ServiceSpec>
{
new("web-http", 80, 8080, "TCP", "ClusterIP")
},
Routes: new List<RouteSpec>
{
new("web-route", "HTTP",
Hostnames: new List<string> { "web.dev.example.com" },
GatewayRef: null,
Rules: new List<RouteRule>
{
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
BackendRefs: new List<RouteBackendRef> { new("web-http", 80, null) },
Filters: null)
})
}));
await repository.AddAsync(app);
// The cluster's ingress is Istio with an internal gateway.
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The route manifest should contain the auto-resolved gateway
// parentRef even though the user never specified one.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("name: internal") && y.Contains("namespace: internal-ingress")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReconcileAsync_RouteWithExplicitGateway_DoesNotOverride()
{
// Arrange — An app with a route that already has an explicit GatewayRef.
// The reconciler should NOT replace it with the cluster's default.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 1,
ContainerPort: 8080,
ServicePort: 80,
HostName: null,
PathPrefix: null,
EnvironmentVariables: null,
Resources: null,
Services: new List<ServiceSpec>
{
new("api-http", 80, 8080, "TCP", "ClusterIP")
},
Routes: new List<RouteSpec>
{
new("api-route", "HTTP",
Hostnames: new List<string> { "api.dev.example.com" },
GatewayRef: new GatewayReference("external", "external-ingress"),
Rules: new List<RouteRule>
{
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
BackendRefs: new List<RouteBackendRef> { new("api-http", 80, null) },
Filters: null)
})
}));
await repository.AddAsync(app);
// The cluster has a default internal gateway, but the route specifies external.
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The route should use the explicit "external" gateway, not "internal".
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("name: external") && y.Contains("namespace: external-ingress")),
It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,128 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class ConfigureAppEnvironmentHandlerTests
{
private readonly ConfigureAppEnvironmentHandler handler;
private readonly InMemoryAppRepository repository;
public ConfigureAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new ConfigureAppEnvironmentHandler(repository);
}
[Fact]
public async Task HandleAsync_DeploymentSpec_ConfiguresEnvironment()
{
// Arrange — An app with an environment that needs deployment config.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
ConfigureDeploymentRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Spec: new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 2,
ContainerPort: 8080,
ServicePort: 80,
HostName: "api.dev.example.com",
PathPrefix: "/",
EnvironmentVariables: new Dictionary<string, string> { ["ENV"] = "dev" },
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi")));
// Act
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.DeploymentSpec.Should().NotBeNull();
env.DeploymentSpec!.Image.Should().Be("registry.example.com/api");
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_HelmSpec_ConfiguresEnvironment()
{
// Arrange — A Helm-type app with an environment that needs chart config.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "redis-dev");
await repository.AddAsync(app);
ConfigureHelmReleaseRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Spec: new HelmReleaseSpec(
RepoUrl: "https://charts.bitnami.com/bitnami",
ChartName: "redis",
ChartVersion: "18.6.1",
ValuesYaml: "replica:\n replicaCount: 3"));
// Act
Result result = await handler.HandleHelmReleaseAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.HelmReleaseSpec.Should().NotBeNull();
env.HelmReleaseSpec!.ChartName.Should().Be("redis");
}
[Fact]
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
{
// Arrange & Act
ConfigureDeploymentRequest request = new(Guid.NewGuid(), Guid.NewGuid(),
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonExistentEnvironment_ReturnsFailure()
{
// Arrange — App exists but the environment ID doesn't match.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
ConfigureDeploymentRequest request = new(app.Id, Guid.NewGuid(),
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
// Act
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,107 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.CreateApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class CreateAppHandlerTests
{
private readonly CreateAppHandler handler;
private readonly InMemoryAppRepository repository;
public CreateAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new CreateAppHandler(repository);
}
[Fact]
public async Task HandleAsync_WithValidRequest_CreatesAppAndReturnsId()
{
// Arrange — A customer admin creates a new Deployment-type app.
CreateAppRequest request = new(
TenantId: Guid.NewGuid(),
CustomerId: Guid.NewGuid(),
Name: "Frontend Portal",
Slug: "frontend-portal",
Type: AppType.Deployment);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The app is created and its ID returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
App? stored = await repository.GetByIdAsync(result.Value!);
stored.Should().NotBeNull();
stored!.Name.Should().Be("Frontend Portal");
stored.Type.Should().Be(AppType.Deployment);
}
[Fact]
public async Task HandleAsync_WithDuplicateSlug_ReturnsFailure()
{
// Arrange — An app with the same slug already exists for this customer.
Guid customerId = Guid.NewGuid();
CreateAppRequest first = new(Guid.NewGuid(), customerId, "App One", "my-app", AppType.Deployment);
await handler.HandleAsync(first);
CreateAppRequest duplicate = new(Guid.NewGuid(), customerId, "App Two", "my-app", AppType.HelmChart);
// Act
Result<Guid> result = await handler.HandleAsync(duplicate);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already exists");
}
[Fact]
public async Task HandleAsync_WithEmptyName_ReturnsFailure()
{
// Arrange & Act
CreateAppRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "", "slug", AppType.Deployment);
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
[Fact]
public async Task HandleAsync_WithHelmChartType_CreatesHelmApp()
{
// Arrange — A customer wants to deploy a Helm chart-based app.
CreateAppRequest request = new(
TenantId: Guid.NewGuid(),
CustomerId: Guid.NewGuid(),
Name: "Redis Cache",
Slug: "redis-cache",
Type: AppType.HelmChart);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? stored = await repository.GetByIdAsync(result.Value!);
stored.Should().NotBeNull();
stored!.Type.Should().Be(AppType.HelmChart);
}
}

View File

@@ -0,0 +1,425 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.DatabaseStatusReconcile;
using EntKube.Provisioning.Features.MongoClusters;
using EntKube.Provisioning.Features.PostgresClusters;
using EntKube.Provisioning.Features.RedisClusters;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for the DatabaseStatusReconcileHandler, which periodically checks
/// Kubernetes CRD status for CNPG, Redis, and MongoDB clusters and transitions
/// the domain models from Provisioning → Running (or Degraded) based on live state.
///
/// This is the missing piece that caused database clusters to stay stuck in
/// "Provisioning" status forever even after Kubernetes reported them as healthy.
/// </summary>
public class DatabaseStatusReconcileHandlerTests
{
private readonly InMemoryPostgresClusterRepository pgRepository;
private readonly InMemoryRedisClusterRepository redisRepository;
private readonly InMemoryMongoClusterRepository mongoRepository;
private readonly Mock<ICnpgClusterClient> cnpgClient;
private readonly Mock<IRedisClusterClient> redisClient;
private readonly Mock<IMongoClusterClient> mongoClient;
private readonly Mock<IClusterCredentialsResolver> credentialsResolver;
private readonly DatabaseStatusReconcileHandler handler;
private const string FakeKubeConfig = "apiVersion: v1\nclusters: []";
private const string FakeContextName = "test-context";
public DatabaseStatusReconcileHandlerTests()
{
pgRepository = new InMemoryPostgresClusterRepository();
redisRepository = new InMemoryRedisClusterRepository();
mongoRepository = new InMemoryMongoClusterRepository();
cnpgClient = new Mock<ICnpgClusterClient>();
redisClient = new Mock<IRedisClusterClient>();
mongoClient = new Mock<IMongoClusterClient>();
credentialsResolver = new Mock<IClusterCredentialsResolver>();
handler = new DatabaseStatusReconcileHandler(
pgRepository,
redisRepository,
mongoRepository,
cnpgClient.Object,
redisClient.Object,
mongoClient.Object,
credentialsResolver.Object);
}
// ─── CNPG PostgreSQL ─────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_CnpgClusterHealthy_TransitionsToRunning()
{
// Arrange — A freshly provisioned CNPG cluster still in Provisioning state.
// Kubernetes reports it as healthy (phase = "Cluster in healthy state").
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "test-db-1", null, null));
// Act — The reconciler checks the status and transitions accordingly.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The cluster should now be Running.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_CnpgClusterNotReady_StaysProvisioning()
{
// Arrange — A CNPG cluster where K8s reports it's still setting up
// (not all instances are ready yet).
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Setting up primary", 0, 3, null, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning; not all instances are ready.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_CnpgClusterAlreadyRunning_SkipsButChecksForDegradation()
{
// Arrange — A cluster that's already Running. The reconciler should check
// its health and mark it Degraded if it has become unhealthy.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "healthy-db", "cnpg-system", "16.4", 3, "50Gi");
pgCluster.MarkRunning();
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "healthy-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 2, 3, "healthy-db-1", null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Not all instances ready → mark degraded.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Degraded);
}
[Fact]
public async Task ReconcileAsync_CnpgUpgrading_CompletesUpgradeWhenHealthy()
{
// Arrange — A cluster that's mid-upgrade. When K8s reports healthy at the
// target version, the reconciler should complete the upgrade.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "upgrade-db", "cnpg-system", "15.6", 3, "50Gi");
pgCluster.MarkRunning();
pgCluster.RequestUpgrade("16.4");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "upgrade-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "upgrade-db-1", null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Upgrade completed, back to Running.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Running);
updated.PostgresVersion.Should().Be("16.4");
}
// ─── Redis ───────────────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_RedisClusterReady_TransitionsToRunning()
{
// Arrange — A freshly provisioned Redis cluster still in Provisioning state.
// Kubernetes reports all replicas are ready.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "test-redis-0"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The Redis cluster should now be Running.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_RedisClusterNotReady_StaysProvisioning()
{
// Arrange — Redis operator reports Unknown phase with no ready replicas.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Unknown", 0, 0, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_RedisUpgrading_CompletesUpgradeWhenReady()
{
// Arrange — Redis cluster mid-upgrade.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "upgrade-redis", "redis", "7.0.15", 3, "10Gi", true);
redis.MarkRunning();
redis.RequestUpgrade("7.2.5");
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "upgrade-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "upgrade-redis-0"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Upgrade completed.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Running);
updated.RedisVersion.Should().Be("7.2.5");
}
// ─── MongoDB ─────────────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_MongoClusterRunningPhase_TransitionsToRunning()
{
// Arrange — A freshly provisioned MongoDB cluster still in Provisioning.
// The operator reports phase = "Running" with all members ready.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "test-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Running", 3, 3, "test-mongo-0", null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The MongoDB cluster should now be Running.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_MongoClusterFailed_MarksDegraded()
{
// Arrange — A MongoDB cluster where the operator reports phase = "Failed".
// This matches the user's scenario where MongoDB shows "Failed" with 0/3 ready.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "failed-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "failed-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Failed", 0, 3, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Failed phase → Degraded with message.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Degraded);
updated.StatusMessage.Should().Contain("Failed");
}
[Fact]
public async Task ReconcileAsync_MongoClusterNotReady_StaysProvisioning()
{
// Arrange — MongoDB with pending phase and 0 ready members.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "pending-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "pending-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Pending", 0, 3, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Provisioning);
}
// ─── Credential resolution failures ──────────────────────────────────
[Fact]
public async Task ReconcileAsync_CredentialResolutionFails_SkipsCluster()
{
// Arrange — Can't reach the Clusters service. The reconciler should skip
// this cluster without crashing and move on to others.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "unreachable-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync((ClusterCredentials?)null);
// Act — Should not throw.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Status unchanged.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_StatusQueryReturnsNull_DoesNotChangeStatus()
{
// Arrange — The K8s status query returns null (cluster CR not found).
// The reconciler should leave the status unchanged.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "missing-cr-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "missing-cr-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync((CnpgClusterStatus?)null);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Status unchanged.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
}

View File

@@ -0,0 +1,64 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.DecommissionService;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class DecommissionServiceHandlerTests
{
private readonly DecommissionServiceHandler handler;
private readonly InMemoryServiceInstanceRepository repository;
public DecommissionServiceHandlerTests()
{
repository = new InMemoryServiceInstanceRepository();
handler = new DecommissionServiceHandler(repository);
}
[Fact]
public async Task HandleAsync_WithRunningService_SetsDesiredStateToDecommissioned()
{
// Arrange — A running service exists in the repository.
ServiceInstance instance = ServiceInstance.Provision(
Guid.NewGuid(),
ServiceType.MinIO,
"tenant-storage",
"minio-system",
Guid.NewGuid());
instance.MarkRunning();
await repository.AddAsync(instance);
// Act — Request decommission.
Result result = await handler.HandleAsync(instance.Id);
// Assert — The desired state should be Decommissioned.
result.IsSuccess.Should().BeTrue();
ServiceInstance? updated = await repository.GetByIdAsync(instance.Id);
updated.Should().NotBeNull();
updated!.DesiredState.Should().Be(ServiceState.Decommissioned);
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,51 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.DeleteApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class DeleteAppHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly DeleteAppHandler handler;
public DeleteAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new DeleteAppHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingApp_DeletesSuccessfully()
{
// Arrange — An existing app in the repository.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(app.Id);
// Assert — The app should be gone from the repository.
result.IsSuccess.Should().BeTrue();
App? deleted = await repository.GetByIdAsync(app.Id);
deleted.Should().BeNull();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,103 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Integration tests for EF Core provisioning repositories. Uses a real SQLite
/// database to verify that change tracking works correctly when adding child
/// entities with pre-set Guid keys to tracked aggregates.
/// </summary>
public class EfIdentityRealmRepositoryTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ProvisioningDbContext db;
private readonly EfIdentityRealmRepository repository;
public EfIdentityRealmRepositoryTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ProvisioningDbContext> options = new DbContextOptionsBuilder<ProvisioningDbContext>()
.UseSqlite(connection)
.Options;
db = new ProvisioningDbContext(options);
db.Database.EnsureCreated();
repository = new EfIdentityRealmRepository(db);
}
[Fact]
public async Task UpdateAsync_AddingIdentityProvider_DoesNotThrowConcurrencyException()
{
// Arrange — Create and persist a realm, then load it back.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
IdentityRealm realm = IdentityRealm.Create(environmentId, "test-realm", clusterId);
await repository.AddAsync(realm);
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
loaded.Should().NotBeNull();
// Act — Add a new identity provider to the tracked realm and save.
// Before the fix, Update() would mark the new provider as Modified
// instead of Added, causing DbUpdateConcurrencyException.
RealmIdentityProvider idp = RealmIdentityProvider.Create(
"google", "Google", IdentityProviderType.Google);
loaded!.AddIdentityProvider(idp);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without throwing.
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
reloaded!.IdentityProviders.Should().HaveCount(1);
reloaded.IdentityProviders[0].Alias.Should().Be("google");
}
[Fact]
public async Task UpdateAsync_AddingOrganization_DoesNotThrowConcurrencyException()
{
// Arrange — Create and persist a realm.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
IdentityRealm realm = IdentityRealm.Create(environmentId, "org-realm", clusterId);
await repository.AddAsync(realm);
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
loaded.Should().NotBeNull();
// Act — Add a new organization and save.
RealmOrganization org = RealmOrganization.Create("Engineering", "Dev teams");
loaded!.AddOrganization(org);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
reloaded!.Organizations.Should().HaveCount(1);
reloaded.Organizations[0].Name.Should().Be("Engineering");
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MinioInstances;
using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance;
using EntKube.Provisioning.Features.PostgresClusters;
using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for MinIO adoption and its integration with CNPG cluster provisioning.
/// MinIO must be adopted before CNPG clusters can be created — the create flow
/// resolves the MinIO details by reference (cluster ID) rather than raw values.
/// </summary>
public class MinioAdoptionTests
{
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly InMemoryPostgresClusterRepository pgRepository;
private readonly Mock<IMinioClient> minioClient;
private readonly Mock<ICnpgClusterClient> cnpgClient;
public MinioAdoptionTests()
{
minioRepository = new InMemoryMinioInstanceRepository();
pgRepository = new InMemoryPostgresClusterRepository();
minioClient = new Mock<IMinioClient>();
cnpgClient = new Mock<ICnpgClusterClient>();
}
// ─── Adopt MinIO ──────────────────────────────────────────────────────────
[Fact]
public async Task AdoptMinio_WithDiscoveredTenant_CreatesRunningInstance()
{
// Arrange — The K8s cluster has an existing MinIO tenant running.
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DiscoveredMinioInstance(
Name: "platform-minio",
Namespace: "minio-system",
Endpoint: "minio.minio-system.svc:9000",
ConsoleEndpoint: "minio-console.minio-system.svc:9001",
CredentialsSecret: "minio-root-credentials",
TotalCapacity: "1Ti",
UsedCapacity: "250Gi",
Buckets: new List<string> { "loki-logs", "velero-backups" }));
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — MinIO adopted and stored with all discovered details.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
all.Should().HaveCount(1);
all[0].Name.Should().Be("platform-minio");
all[0].Endpoint.Should().Be("minio.minio-system.svc:9000");
all[0].Status.Should().Be(MinioInstanceStatus.Running);
all[0].Buckets.Should().Contain("loki-logs");
all[0].Buckets.Should().Contain("velero-backups");
}
[Fact]
public async Task AdoptMinio_NoTenantFound_ReturnsFailure()
{
// Arrange — No MinIO tenant on this cluster.
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((DiscoveredMinioInstance?)null);
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No MinIO");
}
[Fact]
public async Task AdoptMinio_AlreadyAdopted_ReturnsExistingId()
{
// Arrange — MinIO was already adopted for this cluster.
Guid clusterId = Guid.NewGuid();
MinioInstance existing = MinioInstance.Adopt(
clusterId, "platform-minio", "minio-system",
"minio:9000", null, "secret", "500Gi", null, null);
await minioRepository.AddAsync(existing);
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DiscoveredMinioInstance(
"platform-minio", "minio-system", "minio:9000",
null, "secret", "500Gi", null, null));
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(clusterId, "config", "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Returns the existing ID without creating a duplicate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be(existing.Id);
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
all.Should().HaveCount(1);
}
// ─── CNPG Create with MinIO Reference ─────────────────────────────────────
[Fact]
public async Task CreatePostgresCluster_WithMinioInstanceId_ResolvesMinioConfig()
{
// Arrange — An adopted MinIO instance exists. When creating a CNPG cluster,
// we reference it by the cluster ID instead of providing raw details.
Guid clusterId = Guid.NewGuid();
MinioInstance minio = MinioInstance.Adopt(
clusterId, "platform-minio", "minio-system",
"minio.minio-system.svc:9000", null,
"minio-root-credentials", "1Ti", null,
new List<string> { "cnpg-backups" });
await minioRepository.AddAsync(minio);
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: clusterId,
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: null,
MinioBucket: "cnpg-backups",
MinioCredentialsSecret: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — MinIO details resolved from the adopted instance.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
clusters[0].BackupConfig.CredentialsSecret.Should().Be("minio-root-credentials");
clusters[0].BackupConfig.Bucket.Should().Be("cnpg-backups");
cnpgClient.Verify(c => c.CreateClusterAsync(
"config", "ctx",
It.Is<CnpgClusterSpec>(s =>
s.MinioEndpoint == "minio.minio-system.svc:9000" &&
s.MinioCredentialsSecret == "minio-root-credentials"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreatePostgresCluster_NoMinioAdopted_CreatesWithoutBackup()
{
// Arrange — No MinIO adopted for this cluster, and no explicit endpoint provided.
// The cluster should still be created, just without backup configuration.
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: null,
MinioBucket: "backups",
MinioCredentialsSecret: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Cluster is created successfully without backup.
result.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task CreatePostgresCluster_WithExplicitMinioDetails_StillWorks()
{
// Arrange — Explicit MinIO details provided (bypass adoption lookup).
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: "custom-minio:9000",
MinioBucket: "custom-bucket",
MinioCredentialsSecret: "custom-secret");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Explicit values used directly.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("custom-minio:9000");
clusters[0].BackupConfig.Bucket.Should().Be("custom-bucket");
clusters[0].BackupConfig.CredentialsSecret.Should().Be("custom-secret");
}
}

View File

@@ -0,0 +1,256 @@
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;
/// <summary>
/// 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.
/// </summary>
public class MinioTenantAdoptionTests
{
private readonly InMemoryMinioTenantRepository tenantRepository;
private readonly Mock<IMinioTenantClient> tenantClient;
public MinioTenantAdoptionTests()
{
tenantRepository = new InMemoryMinioTenantRepository();
tenantClient = new Mock<IMinioTenantClient>();
}
// ─── 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "platform-storage",
Namespace: "minio-platform",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
},
CurrentState: "Initialized",
Buckets: new List<string> { "cnpg-backups", "loki-logs" }),
new(
Name: "app-storage",
Namespace: "minio-apps",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 2, VolumesPerServer: 4, StorageSize: "50Gi", StorageClass: "standard")
},
CurrentState: "Initialized",
Buckets: new List<string> { "user-uploads" })
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(
ClusterId: clusterId,
KubeConfig: "kubeconfig-content",
ContextName: "prod-context");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both tenants adopted with correct details.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<MinioTenant> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>());
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> 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<MinioTenantPool>
{
new(4, 4, "100Gi", "ceph-block")
});
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.DiscoverTenantsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "platform-storage",
Namespace: "minio-platform",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
},
CurrentState: "Initialized",
Buckets: new List<string> { "cnpg-backups" }),
new(
Name: "new-tenant",
Namespace: "minio-new",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 2, VolumesPerServer: 2, StorageSize: "50Gi", StorageClass: "standard")
},
CurrentState: "Initialized",
Buckets: new List<string>())
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(clusterId, "config", "ctx");
// Act
Result<List<Guid>> 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<MinioTenant> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "sick-tenant",
Namespace: "minio-sick",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
},
CurrentState: "Decommissioning",
Buckets: new List<string>())
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MinioTenant> 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<MinioTenantPool> 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<MinioTenantPool> { new(1, 1, "10Gi", "standard") });
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,209 @@
using EntKube.Provisioning.Features.MinioTenants;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for parsing MinIO tenant credentials from the various secret formats
/// used by different MinIO Operator versions. The operator has changed its
/// credential storage format over time:
///
/// - Legacy (v4): separate "accesskey" and "secretkey" keys in the K8s secret
/// - v5+: a single "config.env" key containing export statements
/// - Console: "CONSOLE_ACCESS_KEY" and "CONSOLE_SECRET_KEY" keys
///
/// The credential parser must handle all formats so mc commands can authenticate
/// against tenants provisioned by any operator version.
/// </summary>
public class MinioTenantCredentialParsingTests
{
// ─── config.env format (MinIO Operator v5+) ────────────────────────────
[Fact]
public void ParseConfigEnv_WithQuotedValues_ExtractsCredentials()
{
// The most common format — double-quoted values with export prefix.
string configEnv = """
export MINIO_ROOT_USER="minio-admin"
export MINIO_ROOT_PASSWORD="super-secret-password"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("super-secret-password");
}
[Fact]
public void ParseConfigEnv_WithSingleQuotedValues_ExtractsCredentials()
{
// Some installations use single quotes.
string configEnv = """
export MINIO_ROOT_USER='admin'
export MINIO_ROOT_PASSWORD='p@ssw0rd'
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("admin");
result!.Value.secretKey.Should().Be("p@ssw0rd");
}
[Fact]
public void ParseConfigEnv_WithUnquotedValues_ExtractsCredentials()
{
// Unquoted values — less common but valid.
string configEnv = """
export MINIO_ROOT_USER=minio
export MINIO_ROOT_PASSWORD=minio123
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio");
result!.Value.secretKey.Should().Be("minio123");
}
[Fact]
public void ParseConfigEnv_WithAdditionalEnvVars_IgnoresExtras()
{
// The config.env may contain other env vars beyond credentials.
// We should extract only the root user/password.
string configEnv = """
export MINIO_ROOT_USER="minio"
export MINIO_ROOT_PASSWORD="secret"
export MINIO_BROWSER="on"
export MINIO_STORAGE_CLASS_STANDARD="EC:2"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio");
result!.Value.secretKey.Should().Be("secret");
}
[Fact]
public void ParseConfigEnv_MissingUser_ReturnsNull()
{
// If the user key is missing, we can't authenticate.
string configEnv = """
export MINIO_ROOT_PASSWORD="secret"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_MissingPassword_ReturnsNull()
{
// If the password key is missing, we can't authenticate.
string configEnv = """
export MINIO_ROOT_USER="admin"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_EmptyString_ReturnsNull()
{
// An empty config.env should not crash — just return null.
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials("");
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_WithWhitespaceAndEmptyLines_ExtractsCredentials()
{
// Real config files often have trailing newlines and inconsistent whitespace.
string configEnv = "\n export MINIO_ROOT_USER=\"root-user\" \n\n export MINIO_ROOT_PASSWORD=\"root-pass\" \n\n";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("root-user");
result!.Value.secretKey.Should().Be("root-pass");
}
[Fact]
public void ParseConfigEnv_SingleLine_ExtractsCredentials()
{
// Some environments produce config.env as a single line with all exports
// separated by spaces. This happens with MinIO Operator v6 in some configurations.
string configEnv = """export MINIO_ROOT_USER="minio-admin" export MINIO_ROOT_PASSWORD="sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7" export MINIO_BROWSER="on" export MINIO_PROMETHEUS_AUTH_TYPE="public" """;
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
}
[Fact]
public void ParseConfigEnv_RealWorldV6Format_ExtractsCredentials()
{
// Exact format from a real MinIO Operator v6 installation.
string configEnv = "export MINIO_ROOT_USER=\"minio-admin\"\nexport MINIO_ROOT_PASSWORD=\"sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7\"\nexport MINIO_BROWSER=\"on\"\nexport MINIO_PROMETHEUS_AUTH_TYPE=\"public\"\n";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
}
}

View File

@@ -0,0 +1,308 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MinioTenants;
using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant;
using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant;
using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for MinIO Tenant provisioning — the full lifecycle of creating, configuring,
/// and deleting MinIO tenants on a Kubernetes cluster. Unlike MinIO adoption (which
/// discovers existing tenants), this provisions new tenants from scratch via the
/// MinIO Operator's Tenant CRD.
/// </summary>
public class MinioTenantProvisioningTests
{
private readonly InMemoryMinioTenantRepository tenantRepository;
private readonly Mock<IMinioTenantClient> tenantClient;
public MinioTenantProvisioningTests()
{
tenantRepository = new InMemoryMinioTenantRepository();
tenantClient = new Mock<IMinioTenantClient>();
}
// ─── Create Tenant ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateTenant_WithValidConfig_ProvisionsTenantOnCluster()
{
// Arrange — A platform admin wants a new MinIO tenant with 4 servers,
// each with 4 disks of 100Gi, using the ceph-block storage class.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "kubeconfig-content",
ContextName: "prod-context",
Name: "platform-storage",
Namespace: "minio-tenant-1",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Tenant created in domain and applied to K8s cluster.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().HaveCount(1);
all[0].Name.Should().Be("platform-storage");
all[0].Namespace.Should().Be("minio-tenant-1");
all[0].Status.Should().Be(MinioTenantStatus.Provisioning);
all[0].Pools.Should().HaveCount(1);
all[0].Pools[0].Servers.Should().Be(4);
all[0].Pools[0].VolumesPerServer.Should().Be(4);
// Verify the K8s client was called to apply the Tenant CRD.
tenantClient.Verify(c => c.ApplyTenantAsync(
"kubeconfig-content", "prod-context",
It.Is<MinioTenant>(t => t.Name == "platform-storage"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateTenant_WithMultiplePools_AllPoolsApplied()
{
// Arrange — Heterogeneous pools: fast NVMe for hot data, HDD for warm.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "multi-tier",
Namespace: "minio-multi",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"),
new(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all[0].Pools.Should().HaveCount(2);
}
[Fact]
public async Task CreateTenant_WithInvalidConfig_ReturnsFailure()
{
// Arrange — Zero servers is not valid.
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "bad-tenant",
Namespace: "minio-bad",
Pools: new List<PoolSpec>
{
new(Servers: 0, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Domain validation catches the bad config.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("server");
}
[Fact]
public async Task CreateTenant_KubernetesClientFails_ReturnsFailure()
{
// Arrange — Network issue or RBAC problem prevents applying the CRD.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Forbidden: RBAC denied"));
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "tenant",
Namespace: "minio-ns",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Failure reported without tenant being persisted.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("RBAC");
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().BeEmpty();
}
// ─── Configure Tenant ──────────────────────────────────────────────────────
[Fact]
public async Task ConfigureTenant_UpdatesPools_AndReapplies()
{
// Arrange — Existing tenant needs scale-out: from 4 to 8 servers.
MinioTenant existing = MinioTenant.Create(
Guid.NewGuid(), "platform-storage", "minio-tenant-1",
new List<MinioTenantPool> { new(4, 4, "100Gi", "ceph-block") });
existing.MarkRunning();
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
ConfigureMinioTenantRequest request = new(
TenantId: existing.Id,
KubeConfig: "config",
ContextName: "ctx",
Pools: new List<PoolSpec>
{
new(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
});
// Act
Result result = await handler.HandleAsync(request);
// Assert — Pools updated, status back to Provisioning, K8s re-applied.
result.IsSuccess.Should().BeTrue();
MinioTenant? updated = await tenantRepository.GetByIdAsync(existing.Id);
updated.Should().NotBeNull();
updated!.Pools[0].Servers.Should().Be(8);
updated.Status.Should().Be(MinioTenantStatus.Provisioning);
tenantClient.Verify(c => c.ApplyTenantAsync(
"config", "ctx",
It.Is<MinioTenant>(t => t.Pools[0].Servers == 8),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureTenant_NotFound_ReturnsFailure()
{
// Arrange
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
ConfigureMinioTenantRequest request = new(
TenantId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Pools: new List<PoolSpec> { new(4, 4, "100Gi", "standard") });
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Delete Tenant ──────────────────────────────────────────────────────────
[Fact]
public async Task DeleteTenant_RemovesFromClusterAndMarksDecommissioned()
{
// Arrange — Platform admin tears down a tenant.
MinioTenant existing = MinioTenant.Create(
Guid.NewGuid(), "old-tenant", "minio-old",
new List<MinioTenantPool> { new(4, 4, "100Gi", "standard") });
existing.MarkRunning();
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.DeleteTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
DeleteMinioTenantRequest request = new(
TenantId: existing.Id,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result result = await handler.HandleAsync(request);
// Assert — Marked decommissioned and deleted from K8s.
result.IsSuccess.Should().BeTrue();
MinioTenant? tenant = await tenantRepository.GetByIdAsync(existing.Id);
tenant.Should().NotBeNull();
tenant!.Status.Should().Be(MinioTenantStatus.Decommissioned);
tenantClient.Verify(c => c.DeleteTenantAsync(
"config", "ctx", "old-tenant", "minio-old",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteTenant_NotFound_ReturnsFailure()
{
// Arrange
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
DeleteMinioTenantRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,642 @@
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");
}
}

View File

@@ -0,0 +1,646 @@
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;
/// <summary>
/// Tests for all PostgreSQL cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class PostgresClusterFeatureTests
{
private readonly InMemoryPostgresClusterRepository repository;
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly Mock<ICnpgClusterClient> cnpgClient;
public PostgresClusterFeatureTests()
{
repository = new InMemoryPostgresClusterRepository();
minioRepository = new InMemoryMinioInstanceRepository();
cnpgClient = new Mock<ICnpgClusterClient>();
}
// ─── 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<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<PostgresCluster> 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<CnpgClusterSpec>(s =>
s.Name == "platform-db" &&
s.PostgresVersion == "16.4" &&
s.Instances == 3 &&
s.MinioEndpoint == "minio.minio-system.svc:9000"),
It.IsAny<CancellationToken>()), 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<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 CNPG clusters. We adopt them
// so the platform is aware of what's already running.
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "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<string> { "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<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<PostgresCluster> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>());
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest 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 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<string> 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<CancellationToken>()), 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<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 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "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<string> 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<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "16.4", "16.5" });
UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object);
UpgradePostgresClusterRequest request = new(pgCluster.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_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<string> 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<CnpgClusterSpec>(s => s.Instances == 5),
It.IsAny<CancellationToken>()), 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<string> 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<CancellationToken>()), 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<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 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<string> { "app_db" });
await repository.AddAsync(existing);
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio:9000", "pg-backups", "minio-creds", true),
new("new-db", "cnpg-staging", "15.8", 1, "20Gi",
new List<string> { "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<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Only the new cluster is adopted, the existing one is updated.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("dev-db", "default", "16.4", 1, "10Gi",
new List<string> { "myapp" },
null, null, null, false)
});
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest 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<PostgresCluster> 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<string> { "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<Guid> 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<RestoreFromBackupSpec>(s =>
s.SourceClusterName == "prod-db" &&
s.RestoredClusterName == "prod-db-restored" &&
s.BackupName == "prod-db-manual-20250510120000" &&
s.Namespace == "cnpg-system"),
It.IsAny<CancellationToken>()), Times.Once);
// The restored cluster should be persisted as a new aggregate.
IReadOnlyList<PostgresCluster> 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<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
cnpgClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromBackupSpec>(s =>
s.TargetTime == targetTime &&
s.RestoredClusterName == "prod-db-pitr"),
It.IsAny<CancellationToken>()), 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<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 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "app_db" },
"minio:9000", "pg-backups", "minio-creds", true)
});
// The live database listing discovers additional databases.
cnpgClient.Setup(c => c.ListLiveDatabasesAsync(
It.IsAny<string>(), It.IsAny<string>(),
"prod-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — All databases from the live cluster are included.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> 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");
}
}

View File

@@ -0,0 +1,290 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.RedisClusters;
using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for all Redis cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class RedisClusterFeatureTests
{
private readonly InMemoryRedisClusterRepository repository;
private readonly Mock<IRedisClusterClient> redisClient;
public RedisClusterFeatureTests()
{
repository = new InMemoryRedisClusterRepository();
redisClient = new Mock<IRedisClusterClient>();
}
// ─── Create Cluster ───────────────────────────────────────────────────────
[Fact]
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
{
// Arrange — A platform admin wants a new 3-replica Redis instance
// with sentinel enabled for HA.
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
CreateRedisClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "platform-cache",
Namespace: "redis",
RedisVersion: "7.2.5",
Replicas: 3,
StorageSize: "10Gi",
SentinelEnabled: true,
KubeConfig: "apiVersion: v1\nkind: Config",
ContextName: "prod-ctx");
// 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<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].Name.Should().Be("platform-cache");
clusters[0].Status.Should().Be(RedisClusterStatus.Provisioning);
redisClient.Verify(c => c.CreateClusterAsync(
"apiVersion: v1\nkind: Config",
"prod-ctx",
It.Is<RedisClusterSpec>(s =>
s.Name == "platform-cache" &&
s.RedisVersion == "7.2.5" &&
s.Replicas == 3 &&
s.SentinelEnabled == true),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateCluster_WithMissingName_ReturnsFailure()
{
// Arrange
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
CreateRedisClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "",
Namespace: "redis",
RedisVersion: "7.2.5",
Replicas: 3,
StorageSize: "10Gi",
SentinelEnabled: true,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Domain validation rejects empty names.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
// ─── Adopt Clusters ───────────────────────────────────────────────────────
[Fact]
public async Task AdoptClusters_DiscoversTwoInstances_CreatesBothInRunningState()
{
// Arrange — The K8s cluster has two existing Redis instances.
redisClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredRedisCluster>
{
new("session-cache", "redis", "7.2.5", 3, "10Gi", true),
new("rate-limiter", "redis", "7.0.15", 1, "5Gi", false)
});
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
AdoptRedisClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both clusters adopted.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(2);
clusters.Should().OnlyContain(c => c.Status == RedisClusterStatus.Running);
}
[Fact]
public async Task AdoptClusters_AlreadyAdopted_ReturnsExistingIdWithoutDuplicate()
{
// Arrange — One instance already adopted.
Guid clusterId = Guid.NewGuid();
RedisCluster existing = RedisCluster.Adopt(
Guid.NewGuid(), clusterId, "session-cache", "redis", "7.2.5", 3, "10Gi", true);
await repository.AddAsync(existing);
redisClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredRedisCluster>
{
new("session-cache", "redis", "7.2.5", 3, "10Gi", true)
});
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
AdoptRedisClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Returns existing ID without creating a duplicate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
result.Value![0].Should().Be(existing.Id);
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
}
// ─── Upgrade ──────────────────────────────────────────────────────────────
[Fact]
public async Task UpgradeCluster_WithValidVersion_TransitionsToUpgrading()
{
// Arrange — A running Redis instance at version 7.0.15.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
redisClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.15", "7.2.5" });
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
redisCluster.Status.Should().Be(RedisClusterStatus.Upgrading);
redisCluster.TargetVersion.Should().Be("7.2.5");
redisClient.Verify(c => c.UpgradeClusterAsync(
"config", "ctx", "cache", "redis", "7.2.5",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpgradeCluster_UnavailableVersion_ReturnsFailure()
{
// Arrange — Target version not in the available list.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
redisClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.15" });
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not available");
}
// ─── Configure ────────────────────────────────────────────────────────────
[Fact]
public async Task ConfigureCluster_ScaleReplicas_UpdatesAndCallsK8s()
{
// Arrange — A running 3-replica cluster to be scaled to 5.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.2.5", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
ConfigureRedisClusterRequest request = new(
redisCluster.Id, Replicas: 5, StorageSize: null, SentinelEnabled: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
redisCluster.Replicas.Should().Be(5);
redisClient.Verify(c => c.UpdateClusterAsync(
"config", "ctx",
It.Is<RedisClusterSpec>(s => s.Replicas == 5),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
{
// Arrange — No cluster with this ID.
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
ConfigureRedisClusterRequest request = new(
Guid.NewGuid(), Replicas: 5, StorageSize: null, SentinelEnabled: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,71 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class RemoveAppEnvironmentHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly RemoveAppEnvironmentHandler handler;
public RemoveAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new RemoveAppEnvironmentHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingEnvironment_RemovesSuccessfully()
{
// Arrange — An app with one environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, envId));
// Assert — The environment should be removed.
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(Guid.NewGuid(), Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonexistentEnvironment_ReturnsFailure()
{
// Arrange — An app with no environments.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,77 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.RemoveAppSecret;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class RemoveAppSecretHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly RemoveAppSecretHandler handler;
public RemoveAppSecretHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new RemoveAppSecretHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingSecret_RemovesSuccessfully()
{
// Arrange — An app with an environment that has a secret.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(app.Id, envId, "DATABASE_URL"));
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].Secrets.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(Guid.NewGuid(), Guid.NewGuid(), "KEY"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonexistentSecret_ReturnsFailure()
{
// Arrange
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(app.Id, envId, "MISSING"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,71 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.SuspendActivateApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class SuspendActivateAppHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly SuspendActivateAppHandler handler;
public SuspendActivateAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new SuspendActivateAppHandler(repository);
}
[Fact]
public async Task SuspendAsync_ActiveApp_SuspendsSuccessfully()
{
// Arrange — An active app.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.SuspendAsync(app.Id);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Status.Should().Be(AppStatus.Suspended);
}
[Fact]
public async Task ActivateAsync_SuspendedApp_ActivatesSuccessfully()
{
// Arrange — A suspended app.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
app.Suspend();
await repository.AddAsync(app);
// Act
Result result = await handler.ActivateAsync(app.Id);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Status.Should().Be(AppStatus.Active);
}
[Fact]
public async Task SuspendAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.SuspendAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,94 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.TriggerSync;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class TriggerSyncHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly TriggerSyncHandler handler;
public TriggerSyncHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new TriggerSyncHandler(repository);
}
[Fact]
public async Task HandleAsync_SyncedEnvironment_MarksPending()
{
// Arrange — An environment that was previously synced. The user
// wants to force a re-sync (e.g., after a config change outside
// the UI or to retry after fixing a problem).
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkSynced();
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
// Assert — Should be marked Pending so the reconciler picks it up.
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_ErrorEnvironment_MarksPendingForRetry()
{
// Arrange — An environment in error state. The admin fixes the issue
// and wants to retry the sync.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkError("Connection refused");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(
new TriggerSyncRequest(Guid.NewGuid(), Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}