using CloudServicesCrud; using DynamicCrud.Tests.Fixtures; using ServicesDAO.models; using ServicesDAO.Repositories; using Xunit; using FilterCondition = ServicesDAO.Repositories.FilterCondition; namespace DynamicCrud.Tests; /// /// Cubre los criterios de aceptación de la fase service_endpoints /// (ver migrations/2026-08_service_endpoints.sql). /// public class ServiceEndpointsTests : IClassFixture { private readonly DbContextFixture _fixture; public ServiceEndpointsTests(DbContextFixture fixture) { _fixture = fixture; } // --- Criterio 2: GetFields devuelve la estructura completa de ambas tablas --- [Theory] [InlineData("service_endpoints", "release_identificator", 17)] [InlineData("service_endpoints", "namespace", 17)] [InlineData("service_endpoints", "ports", 17)] [InlineData("service_connection_info", "endpoint_strategy", 11)] [InlineData("service_connection_info", "template", 11)] public async Task GetFields_NewModels_ExposesExpectedColumn( string modelName, string expectedColumn, int expectedCount) { using var ctx = _fixture.CreateContext(); var service = new server.Services.DynamicCrudService(ctx); var response = await service.GetFields(new GetFieldsRequest { Model = modelName }, context: null!); Assert.True(response.Success, $"[{modelName}] {response.Message}"); Assert.Contains(response.Fields, f => f.Column == expectedColumn); Assert.Equal(expectedCount, response.Fields.Count); } // --- Criterio 3: el Upsert con las 4 columnas de conflicto actualiza, no duplica --- // Es exactamente como sincronizará k8s_monitor_services: conflict_fields // compuestos que empatan con uk_service_endpoints_release. [Fact] public async Task Upsert_ServiceEndpoints_CompositeKey_UpdatesInsteadOfDuplicating() { var entityType = ModelRegistry.Resolve("service_endpoints")!; var requestQueueId = await GetARequestQueueId(); var statusId = await GetServiceEndpointStatusId("Active"); var release = $"test-se-{Guid.NewGuid():N}"; var k8sContext = "test-context-se"; var resourceName = $"svc-{Guid.NewGuid():N}"; var conflictFields = new[] { "ReleaseIdentificator", "K8sContext", "ResourceKind", "ResourceName" }; // Las seis columnas NOT NULL van SIEMPRE en el payload: si el Upsert cae en la // rama de alta, EF Core nunca omite una columna del INSERT, así que una ausente // viajaría como NULL y violaría la restricción. Dictionary Payload(string? externalIp, string phase) => new() { ["requestQueueId"] = requestQueueId, ["k8sContext"] = k8sContext, ["namespace"] = "daas", ["releaseIdentificator"] = release, ["resourceKind"] = "Service", ["resourceName"] = resourceName, ["serviceType"] = "LoadBalancer", ["externalIp"] = externalIp, // `ports` va como CADENA ya serializada, nunca como estructura anidada: // ConvertValue no sabe convertir un Dictionary/List a string y devuelve // null en silencio, dejando la columna vacía con el Upsert en éxito. ["ports"] = "[{\"name\":\"restAPI\",\"port\":9200,\"targetPort\":9200,\"protocol\":\"TCP\"}]", ["statusId"] = statusId, ["syncedAt"] = DateTime.UtcNow, }; // 1. Alta: el Service todavía no tiene IP externa asignada. using var ctx1 = _fixture.CreateContext(); var (insertOk, _, insertStatus, insertedId, insertDetail) = await RepoHelper.Upsert(RepoHelper.Create(ctx1, entityType), Payload(null, "Pending"), conflictFields); Assert.True(insertOk, $"Upsert (insert) falló: {insertDetail}"); Assert.Equal("success", insertStatus); Assert.NotNull(insertedId); try { // 2. El balanceador ya asignó IP: mismo objeto, segunda observación. using var ctx2 = _fixture.CreateContext(); var (updateOk, _, _, updatedInsertedId, updateDetail) = await RepoHelper.Upsert(RepoHelper.Create(ctx2, entityType), Payload("10.252.8.99", "Active"), conflictFields); Assert.True(updateOk, $"Upsert (update) falló: {updateDetail}"); Assert.Null(updatedInsertedId); // insertedId nulo == tomó la rama de UPDATE // 3. Una sola fila, con el valor nuevo. using var ctx3 = _fixture.CreateContext(); var matches = (await RepoHelper.Search( RepoHelper.Create(ctx3, entityType), and: new[] { new FilterCondition("releaseIdentificator", "eq", release), new FilterCondition("k8sContext", "eq", k8sContext), })).Cast().ToList(); Assert.Single(matches); Assert.Equal("10.252.8.99", matches[0].ExternalIp); Assert.Contains("restAPI", matches[0].Ports); // el JSON sobrevivió como cadena } finally { using var cleanupCtx = _fixture.CreateContext(); await RepoHelper.Delete(RepoHelper.Create(cleanupCtx, entityType), insertedId!); } } // --- Criterio 4: el índice único es la protección contra la carrera del Upsert --- // El Upsert del DAO no es atómico: hay una ventana entre su SELECT y el // SaveChanges en la que dos escritores pueden ver "no existe" y ambos intentar // INSERT. El índice único es lo único que lo impide, así que se prueba directo. [Fact] public async Task Create_ServiceEndpoints_DuplicateCompositeKey_ViolatesUniqueIndex() { var entityType = ModelRegistry.Resolve("service_endpoints")!; var requestQueueId = await GetARequestQueueId(); var statusId = await GetServiceEndpointStatusId("Active"); var release = $"test-dup-{Guid.NewGuid():N}"; var resourceName = $"svc-{Guid.NewGuid():N}"; Dictionary Payload() => new() { ["requestQueueId"] = requestQueueId, ["k8sContext"] = "test-context-dup", ["namespace"] = "daas", ["releaseIdentificator"] = release, ["resourceKind"] = "Service", ["resourceName"] = resourceName, ["statusId"] = statusId, }; using var ctx1 = _fixture.CreateContext(); var (firstOk, _, firstId, firstDetail) = await RepoHelper.CreateRecord(RepoHelper.Create(ctx1, entityType), Payload()); Assert.True(firstOk, $"El primer insert debería pasar: {firstDetail}"); try { using var ctx2 = _fixture.CreateContext(); var (secondOk, _, secondId, secondDetail) = await RepoHelper.CreateRecord(RepoHelper.Create(ctx2, entityType), Payload()); Assert.False(secondOk, "El segundo insert con la misma combinación debería fallar."); Assert.Null(secondId); Assert.Contains("Duplicate", secondDetail ?? string.Empty, StringComparison.OrdinalIgnoreCase); } finally { using var cleanupCtx = _fixture.CreateContext(); await RepoHelper.Delete(RepoHelper.Create(cleanupCtx, entityType), firstId!); } } // --- Criterio 5: cluster acepta y devuelve dns_zone y dns_resolver_ip --- [Fact] public async Task Cluster_AcceptsAndReturnsDnsColumns() { var entityType = ModelRegistry.Resolve("cluster")!; using var readCtx = _fixture.CreateContext(); var cluster = (await RepoHelper.GetAll(RepoHelper.Create(readCtx, entityType), limit: 1)) .Cast().FirstOrDefault(); Assert.True(cluster is not null, "La tabla cluster está vacía — este test necesita al menos una fila."); var originalZone = cluster!.DnsZone; var originalResolver = cluster.DnsResolverIp; try { using var writeCtx = _fixture.CreateContext(); var (ok, message, _) = await RepoHelper.Update( RepoHelper.Create(writeCtx, entityType), cluster.IdCluster, new Dictionary { ["dnsZone"] = "qr.triara.com", ["dnsResolverIp"] = "10.252.8.20", }); Assert.True(ok, message); using var verifyCtx = _fixture.CreateContext(); var reloaded = (Cluster?)await RepoHelper.GetById( RepoHelper.Create(verifyCtx, entityType), cluster.IdCluster); Assert.NotNull(reloaded); Assert.Equal("qr.triara.com", reloaded!.DnsZone); Assert.Equal("10.252.8.20", reloaded.DnsResolverIp); } finally { // Devolver la fila a su valor original: es una tabla de datos reales. using var restoreCtx = _fixture.CreateContext(); await RepoHelper.Update( RepoHelper.Create(restoreCtx, entityType), cluster.IdCluster, new Dictionary { ["dnsZone"] = originalZone, ["dnsResolverIp"] = originalResolver, }); } } // --- Criterio 6: las cuatro fases existen en status_catalog --- [Theory] [InlineData("Active")] [InlineData("Pending")] [InlineData("Stale")] [InlineData("Removed")] public async Task StatusCatalog_HasServiceEndpointPhase(string phase) { var statusId = await GetServiceEndpointStatusId(phase); Assert.True(statusId > 0); } // --- Auxiliares --- // request_queue_id es NOT NULL y con FK: un endpoint cuyo release no empate con // una fila de requests_queue no es un despliegue válido originado en la interfaz. private async Task GetARequestQueueId() { using var ctx = _fixture.CreateContext(); var row = (await RepoHelper.GetAll(RepoHelper.Create(ctx, typeof(RequestQueue)), limit: 1)) .Cast().FirstOrDefault(); Assert.True(row is not null, "requests_queue está vacía — necesaria para satisfacer la FK de service_endpoints."); return row!.RequestQueueID; } // Las fases del dominio 'service_endpoint' las siembra // migrations/2026-08_service_endpoints.sql, sección 4. private async Task GetServiceEndpointStatusId(string phase) { using var typeCtx = _fixture.CreateContext(); var endpointType = (await RepoHelper.Search( RepoHelper.Create(typeCtx, typeof(StatusType)), and: new[] { new FilterCondition("name", "eq", "service_endpoint") })) .Cast().FirstOrDefault(); Assert.True(endpointType is not null, "status_type no tiene la fila 'service_endpoint' — corre " + "migrations/2026-08_service_endpoints.sql (sección 4)."); using var catalogCtx = _fixture.CreateContext(); var status = (await RepoHelper.Search( RepoHelper.Create(catalogCtx, typeof(StatusCatalog)), and: new[] { new FilterCondition("idType", "eq", endpointType!.StatusTypeID.ToString()), new FilterCondition("status", "eq", phase), })) .Cast().FirstOrDefault(); Assert.True(status is not null, $"status_catalog no tiene la fase '{phase}' del dominio 'service_endpoint' — corre " + "migrations/2026-08_service_endpoints.sql (sección 4)."); return status!.StatusCatalogID; } }