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;
}
}