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 applyClient; private readonly Mock secretResolver; private readonly Mock gatewayResolver; private readonly AppReconcileHandler handler; public AppReconcilerTests() { repository = new InMemoryAppRepository(); applyClient = new Mock(); secretResolver = new Mock(); gatewayResolver = new Mock(); 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(y => y.Contains("kind: Deployment")), It.IsAny()), Times.Once); applyClient.Verify(c => c.ApplyManifestAsync( clusterId, It.Is(y => y.Contains("kind: Service")), It.IsAny()), Times.Once); applyClient.Verify(c => c.ApplyManifestAsync( clusterId, It.Is(y => y.Contains("kind: HTTPRoute")), It.IsAny()), 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())) .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(y => y.Contains("kind: Secret")), It.IsAny()), Times.Once); // The Deployment manifest should include secretKeyRef for the secret. applyClient.Verify(c => c.ApplyManifestAsync( clusterId, It.Is(y => y.Contains("secretKeyRef:")), It.IsAny()), 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(s => s.ChartName == "redis" && s.ChartVersion == "18.6.1"), It.IsAny()), 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(), It.IsAny())) .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(), It.IsAny(), It.IsAny()), Times.Never); applyClient.Verify(c => c.HelmUpgradeInstallAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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(), It.IsAny(), It.IsAny()), 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 { new("web-http", 80, 8080, "TCP", "ClusterIP") }, Routes: new List { new("web-route", "HTTP", Hostnames: new List { "web.dev.example.com" }, GatewayRef: null, Rules: new List { new(Matches: new List { new("PathPrefix", "/", null) }, BackendRefs: new List { 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())) .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(y => y.Contains("name: internal") && y.Contains("namespace: internal-ingress")), It.IsAny()), 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 { new("api-http", 80, 8080, "TCP", "ClusterIP") }, Routes: new List { new("api-route", "HTTP", Hostnames: new List { "api.dev.example.com" }, GatewayRef: new GatewayReference("external", "external-ingress"), Rules: new List { new(Matches: new List { new("PathPrefix", "/", null) }, BackendRefs: new List { 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())) .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(y => y.Contains("name: external") && y.Contains("namespace: external-ingress")), It.IsAny()), Times.Once); } }