using EntKube.Web.Data; using EntKube.Web.Services; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace EntKube.Web.Tests; /// /// Tests for the DeploymentService which manages app deployments — creating /// deployments (Manual, Yaml, Helm), managing manifests, and tracking status. /// Uses SQLite in-memory for fast, isolated database tests. /// public class DeploymentServiceTests : IDisposable { private readonly SqliteConnection connection; private readonly ApplicationDbContext db; private readonly TestDbContextFactory dbFactory; private readonly DeploymentService sut; public DeploymentServiceTests() { // Stand up a fresh in-memory SQLite database for each test. // This gives us real EF Core behavior without touching disk. connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); DbContextOptions options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; db = new ApplicationDbContext(options); dbFactory = new TestDbContextFactory(connection); db.Database.EnsureCreated(); sut = new DeploymentService(dbFactory); } public void Dispose() { db.Dispose(); connection.Dispose(); } // ── Helpers ── /// /// Sets up a tenant with an environment, cluster, customer, and app — the /// minimum scaffolding needed to create a deployment. /// private (App app, Data.Environment env, KubernetesCluster cluster) CreateTestApp() { Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; db.Tenants.Add(tenant); Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "production" }; db.Environments.Add(env); KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "prod-cluster", ApiServerUrl = "https://k8s.example.com" }; db.KubernetesClusters.Add(cluster); Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Contoso" }; db.Customers.Add(customer); App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" }; db.Apps.Add(app); db.SaveChanges(); return (app, env, cluster); } // ════════════════════════════════════════════════════════════════ // CreateDeploymentAsync // ════════════════════════════════════════════════════════════════ [Fact] public async Task CreateDeploymentAsync_ManualType_CreatesWithUnknownStatus() { // Arrange — we need an app, environment, and cluster to target. (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); // Act — create a manual deployment targeting the prod cluster. AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "billing-deploy", DeploymentType.Manual, env.Id, cluster.Id, "billing-ns"); // Assert — the deployment exists with the right defaults. deployment.Id.Should().NotBeEmpty(); deployment.Name.Should().Be("billing-deploy"); deployment.Type.Should().Be(DeploymentType.Manual); deployment.Namespace.Should().Be("billing-ns"); deployment.SyncStatus.Should().Be(SyncStatus.Unknown); deployment.HealthStatus.Should().Be(HealthStatus.Unknown); } [Fact] public async Task CreateDeploymentAsync_HelmType_StoresChartInfo() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); // Act — create a Helm deployment with chart details. AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "minio-deploy", DeploymentType.HelmChart, env.Id, cluster.Id, "minio-ns", helmRepoUrl: "https://charts.min.io", helmChartName: "minio", helmChartVersion: "5.0.0"); // Assert deployment.Type.Should().Be(DeploymentType.HelmChart); deployment.HelmRepoUrl.Should().Be("https://charts.min.io"); deployment.HelmChartName.Should().Be("minio"); deployment.HelmChartVersion.Should().Be("5.0.0"); } [Fact] public async Task CreateDeploymentAsync_DuplicateName_Throws() { // Arrange — create a deployment, then try to create another with the same name. (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); await sut.CreateDeploymentAsync( app.Id, "billing-deploy", DeploymentType.Manual, env.Id, cluster.Id, "billing-ns"); // Act & Assert Func act = () => sut.CreateDeploymentAsync( app.Id, "billing-deploy", DeploymentType.Manual, env.Id, cluster.Id, "other-ns"); await act.Should().ThrowAsync(); } // ════════════════════════════════════════════════════════════════ // GetDeploymentsAsync // ════════════════════════════════════════════════════════════════ [Fact] public async Task GetDeploymentsAsync_ReturnsDeploymentsForApp() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); await sut.CreateDeploymentAsync(app.Id, "deploy-2", DeploymentType.HelmChart, env.Id, cluster.Id, "ns2"); // Act List deployments = await sut.GetDeploymentsAsync(app.Id); // Assert — both deployments returned with related data loaded. deployments.Should().HaveCount(2); deployments.Should().Contain(d => d.Name == "deploy-1"); deployments.Should().Contain(d => d.Name == "deploy-2"); } [Fact] public async Task GetDeploymentsAsync_IncludesEnvironmentAndCluster() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); // Act List deployments = await sut.GetDeploymentsAsync(app.Id); // Assert — navigation properties should be loaded. deployments[0].Environment.Name.Should().Be("production"); deployments[0].Cluster.Name.Should().Be("prod-cluster"); } // ════════════════════════════════════════════════════════════════ // DeleteDeploymentAsync // ════════════════════════════════════════════════════════════════ [Fact] public async Task DeleteDeploymentAsync_RemovesDeployment() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "to-delete", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); // Act await sut.DeleteDeploymentAsync(deployment.Id); // Assert List remaining = await sut.GetDeploymentsAsync(app.Id); remaining.Should().BeEmpty(); } // ════════════════════════════════════════════════════════════════ // Manifest CRUD // ════════════════════════════════════════════════════════════════ [Fact] public async Task AddManifestAsync_CreatesManifest() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "yaml-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); string yaml = "apiVersion: v1\nkind: Service\nmetadata:\n name: billing-svc"; // Act — add a manifest to the deployment. DeploymentManifest manifest = await sut.AddManifestAsync( deployment.Id, "Service", "billing-svc", yaml, sortOrder: 1); // Assert manifest.Id.Should().NotBeEmpty(); manifest.Kind.Should().Be("Service"); manifest.Name.Should().Be("billing-svc"); manifest.YamlContent.Should().Be(yaml); } [Fact] public async Task GetManifestsAsync_ReturnsOrderedManifests() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "ordered-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); await sut.AddManifestAsync(deployment.Id, "Service", "svc", "svc-yaml", sortOrder: 2); await sut.AddManifestAsync(deployment.Id, "Deployment", "dep", "dep-yaml", sortOrder: 1); await sut.AddManifestAsync(deployment.Id, "PersistentVolumeClaim", "pvc", "pvc-yaml", sortOrder: 0); // Act List manifests = await sut.GetManifestsAsync(deployment.Id); // Assert — returned in SortOrder (PVC → Deployment → Service). manifests.Should().HaveCount(3); manifests[0].Kind.Should().Be("PersistentVolumeClaim"); manifests[1].Kind.Should().Be("Deployment"); manifests[2].Kind.Should().Be("Service"); } [Fact] public async Task UpdateManifestAsync_UpdatesYamlContent() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "update-test", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); DeploymentManifest manifest = await sut.AddManifestAsync( deployment.Id, "Deployment", "billing", "old-yaml", sortOrder: 0); // Act await sut.UpdateManifestAsync(manifest.Id, "new-yaml-content"); // Assert List manifests = await sut.GetManifestsAsync(deployment.Id); manifests[0].YamlContent.Should().Be("new-yaml-content"); } [Fact] public async Task DeleteManifestAsync_RemovesManifest() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "del-manifest", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); DeploymentManifest manifest = await sut.AddManifestAsync( deployment.Id, "Service", "svc", "yaml", sortOrder: 0); // Act await sut.DeleteManifestAsync(manifest.Id); // Assert List remaining = await sut.GetManifestsAsync(deployment.Id); remaining.Should().BeEmpty(); } // ════════════════════════════════════════════════════════════════ // Helm Values // ════════════════════════════════════════════════════════════════ [Fact] public async Task UpdateHelmValuesAsync_StoresYaml() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "helm-deploy", DeploymentType.HelmChart, env.Id, cluster.Id, "minio-ns", helmRepoUrl: "https://charts.min.io", helmChartName: "minio", helmChartVersion: "5.0.0"); string values = "replicas: 3\npersistence:\n size: 10Gi"; // Act await sut.UpdateHelmValuesAsync(deployment.Id, values); // Assert — reload from DB to verify persistence. List deployments = await sut.GetDeploymentsAsync(app.Id); deployments[0].HelmValues.Should().Be(values); } // ════════════════════════════════════════════════════════════════ // Deployment Resources (ArgoCD-style tree) // ════════════════════════════════════════════════════════════════ [Fact] public async Task UpsertResourceAsync_CreatesNewResource() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "res-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); // Act — the cluster watcher reports a Deployment resource. DeploymentResource resource = await sut.UpsertResourceAsync( deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3 replicas"); // Assert resource.Id.Should().NotBeEmpty(); resource.Kind.Should().Be("Deployment"); resource.SyncStatus.Should().Be(SyncStatus.Synced); resource.HealthStatus.Should().Be(HealthStatus.Healthy); } [Fact] public async Task UpsertResourceAsync_UpdatesExistingResource() { // Arrange — create a resource, then update its health. (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "upsert-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); await sut.UpsertResourceAsync( deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", SyncStatus.Synced, HealthStatus.Progressing, "Rolling update"); // Act — same resource, updated status. await sut.UpsertResourceAsync( deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3"); // Assert — only one resource, with the updated health. List resources = await sut.GetResourceTreeAsync(deployment.Id); resources.Should().HaveCount(1); resources[0].HealthStatus.Should().Be(HealthStatus.Healthy); resources[0].StatusMessage.Should().Be("Running 3/3"); } [Fact] public async Task GetResourceTreeAsync_ReturnsOnlyRootResources() { // Arrange — build a two-level tree: Deployment → ReplicaSet. (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "tree-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); DeploymentResource parent = await sut.UpsertResourceAsync( deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", SyncStatus.Synced, HealthStatus.Healthy, null); await sut.UpsertResourceAsync( deployment.Id, "apps", "v1", "ReplicaSet", "billing-api-abc123", "ns1", SyncStatus.Synced, HealthStatus.Healthy, null, parentResourceId: parent.Id); // Act — get the tree roots. List roots = await sut.GetResourceTreeAsync(deployment.Id); // Assert — only the Deployment root, with the ReplicaSet as a child. roots.Should().HaveCount(1); roots[0].Kind.Should().Be("Deployment"); roots[0].ChildResources.Should().HaveCount(1); roots[0].ChildResources.First().Kind.Should().Be("ReplicaSet"); } // ════════════════════════════════════════════════════════════════ // UpdateDeploymentStatusAsync // ════════════════════════════════════════════════════════════════ [Fact] public async Task UpdateDeploymentStatusAsync_UpdatesStatusFields() { // Arrange (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); AppDeployment deployment = await sut.CreateDeploymentAsync( app.Id, "status-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); // Act — simulate a sync completion. await sut.UpdateDeploymentStatusAsync( deployment.Id, SyncStatus.Synced, HealthStatus.Healthy, "All resources healthy"); // Assert List deployments = await sut.GetDeploymentsAsync(app.Id); deployments[0].SyncStatus.Should().Be(SyncStatus.Synced); deployments[0].HealthStatus.Should().Be(HealthStatus.Healthy); deployments[0].StatusMessage.Should().Be("All resources healthy"); deployments[0].LastSyncedAt.Should().NotBeNull(); } }