From 8d9007a2d2cf670b0367f1c2c76e26e135c480fc Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 20:36:39 -0500 Subject: [PATCH] feat(tracker-api): GT-615 consulta por revision y senal de drift sobre el ledger de evaluaciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El ledger `core_evaluation_transactions` persistia `repository_revision` junto al veredicto que el Core emitio para esa revision, pero solo se podia interrogar de dos maneras: «todas las del tenant» (`GET /`) o «una por id» (`GET /{id}`). No habia filtro por repositorio, ni corte temporal, ni orden de revision, ni lectura alguna que comparase revisiones consecutivas. El sustrato para detectar deriva existia y no emitia nada. Lo que se anade: - `ICoreEvaluationTransactionRepository.GetRevisionTimelineAsync(tenant, repositoryUrl?, sinceUtc?)`: filtros opcionales combinables y ORDEN DE REVISION (la mas antigua primero, desempatando por id para que la serie sea total). Es deliberadamente el inverso del listado del ledger, que responde a otra pregunta y no se toca. - `RepositoryRevisionTimeline` (dominio, puro): proyeccion de veredicto por revision y deteccion de `DriftDetected` cuando el veredicto cambia entre revisiones consecutivas del MISMO repositorio. Las evaluaciones pendientes y las fallidas no cuentan como veredicto —una caida del Core no es una opinion sobre la arquitectura— pero tampoco cortan la serie. - `GET /core-evaluation-transactions?repositoryUrl=&since=`: los dos filtros que faltaban. Sin ellos la respuesta es identica a la de siempre. - `GET /core-evaluation-transactions/revisions?repositoryUrl=&since=`: la linea de revisiones con su proyeccion de veredicto y las senales derivadas. Exige `tracker:drift:read`, un permiso que llevaba declarado en el vocabulario sin que ninguna ruta lo pidiese. La senal se CALCULA sobre el ledger, no se almacena: el asiento de evaluacion sigue siendo el unico sistema de registro y una tabla paralela solo podria contradecirlo. Rojo antes / verde despues: con el codigo de produccion previo las tres suites nuevas ni compilan (`dotnet test` sale con 1); revirtiendo solo la SEMANTICA —consulta sin filtros y en orden del ledger, `DetectDrift` devolviendo vacio— caen 10 de 16 con aserciones reales. Con el arreglo, 16/16 en verde y la suite completa en 1035/1037 (los 2 rojos son los mismos de la linea base: exigen un PostgreSQL local que aqui no hay). Co-Authored-By: Claude Opus 5 --- .../DTOs/RepositoryRevisionTimelineDto.cs | 53 +++++ .../GetRepositoryRevisionTimelineQuery.cs | 14 ++ ...tRepositoryRevisionTimelineQueryHandler.cs | 57 ++++++ .../CoreEvaluationTransaction/Repositories.cs | 17 ++ .../RepositoryRevisionTimeline.cs | 190 +++++++++++++++++ .../InMemory/InMemoryRepositories.cs | 23 +++ ...eSqlCoreEvaluationTransactionRepository.cs | 47 +++++ .../CoreEvaluationTransactionEndpoints.cs | 60 +++++- ...sitoryRevisionTimelineQueryHandlerTests.cs | 134 ++++++++++++ .../RepositoryRevisionTimelineTests.cs | 192 ++++++++++++++++++ ...EvaluationTransactionRevisionQueryTests.cs | 133 ++++++++++++ 11 files changed, 917 insertions(+), 3 deletions(-) create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs new file mode 100644 index 00000000..e4aecb52 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs @@ -0,0 +1,53 @@ +namespace Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; + +/// +/// GT-615 — la linea de revisiones de un repositorio con su proyeccion de veredicto y las senales +/// de drift derivadas. Es un DTO de LECTURA: nada de esto se persiste aparte, se calcula sobre el +/// ledger core_evaluation_transactions. +/// +public sealed class RepositoryRevisionTimelineDto +{ + /// Repositorio consultado, o null si la linea abarca todo el tenant. + public string? RepositoryUrl { get; init; } + + /// Limite inferior aplicado sobre requestedAt, o null si no se acoto. + public DateTime? SinceUtc { get; init; } + + /// Revisiones en orden de revision (la mas antigua primero). + public IReadOnlyList Revisions { get; init; } = []; + + /// Senales DriftDetected derivadas de los cambios de veredicto. + public IReadOnlyList DriftSignals { get; init; } = []; +} + +/// Una revision evaluada dentro de la linea. +public sealed class RepositoryRevisionDto +{ + public int Sequence { get; init; } + public Guid TransactionId { get; init; } + public string OperationId { get; init; } = string.Empty; + public string RepositoryUrl { get; init; } = string.Empty; + public string RepositoryRevision { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string Verdict { get; init; } = string.Empty; + + /// false cuando el Core no llego a pronunciarse (pendiente o error de transporte). + public bool Decided { get; init; } + + public DateTime ObservedAtUtc { get; init; } +} + +/// Cambio de veredicto entre dos revisiones consecutivas del mismo repositorio. +public sealed class DriftSignalDto +{ + public string Signal { get; init; } = string.Empty; + public string RepositoryUrl { get; init; } = string.Empty; + public string FromRevision { get; init; } = string.Empty; + public string ToRevision { get; init; } = string.Empty; + public string FromVerdict { get; init; } = string.Empty; + public string ToVerdict { get; init; } = string.Empty; + public Guid FromTransactionId { get; init; } + public Guid ToTransactionId { get; init; } + public bool SameRevision { get; init; } + public DateTime DetectedAtUtc { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs new file mode 100644 index 00000000..6a9f2b8b --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs @@ -0,0 +1,14 @@ +using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; + +namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline; + +/// +/// GT-615 — pregunta por la linea de revisiones de un repositorio. TenantId lo resuelve el +/// llamante desde la identidad, nunca el cuerpo: el aislamiento entre tenants es del perimetro +/// (T-044) y esta consulta no es la excepcion. +/// +public sealed record GetRepositoryRevisionTimelineQuery( + Guid TenantId, + string? RepositoryUrl, + DateTime? SinceUtc +) : IQuery; diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs new file mode 100644 index 00000000..fd442c37 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs @@ -0,0 +1,57 @@ +using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline; + +internal sealed class GetRepositoryRevisionTimelineQueryHandler + : IQueryHandler +{ + private readonly ICoreEvaluationTransactionRepository _repository; + + public GetRepositoryRevisionTimelineQueryHandler(ICoreEvaluationTransactionRepository repository) + { + _repository = repository; + } + + public async Task Handle( + GetRepositoryRevisionTimelineQuery request, + CancellationToken cancellationToken) + { + var transactions = await _repository.GetRevisionTimelineAsync( + request.TenantId, request.RepositoryUrl, request.SinceUtc, cancellationToken); + + var entries = RepositoryRevisionTimeline.Project(transactions); + var signals = RepositoryRevisionTimeline.DetectDrift(entries); + + return new RepositoryRevisionTimelineDto + { + RepositoryUrl = string.IsNullOrWhiteSpace(request.RepositoryUrl) ? null : request.RepositoryUrl.Trim(), + SinceUtc = request.SinceUtc, + Revisions = entries.Select(entry => new RepositoryRevisionDto + { + Sequence = entry.Sequence, + TransactionId = entry.TransactionId, + OperationId = entry.OperationId, + RepositoryUrl = entry.RepositoryUrl, + RepositoryRevision = entry.RepositoryRevision, + Status = entry.Status, + Verdict = entry.Verdict, + Decided = entry.Decided, + ObservedAtUtc = entry.ObservedAtUtc + }).ToList(), + DriftSignals = signals.Select(signal => new DriftSignalDto + { + Signal = signal.Signal, + RepositoryUrl = signal.RepositoryUrl, + FromRevision = signal.FromRevision, + ToRevision = signal.ToRevision, + FromVerdict = signal.FromVerdict, + ToVerdict = signal.ToVerdict, + FromTransactionId = signal.FromTransactionId, + ToTransactionId = signal.ToTransactionId, + SameRevision = signal.SameRevision, + DetectedAtUtc = signal.DetectedAtUtc + }).ToList() + }; + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs index d50864f8..769d6209 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs @@ -8,4 +8,21 @@ public interface ICoreEvaluationTransactionRepository Task DeleteAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default); Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default); + + /// + /// GT-615 — la linea de revisiones de un repositorio, en ORDEN DE REVISION (la mas antigua + /// primero). Es el orden que necesita : el drift solo + /// tiene sentido leyendo la serie hacia adelante. Es deliberadamente el inverso del listado + /// del ledger (, mas reciente primero), que responde a otra + /// pregunta —«que ha pasado ultimamente»— y no se toca. + /// + /// Ambos filtros son opcionales y se combinan: acota a un + /// repositorio (comparacion exacta; el ledger guarda la URL tal cual la mando el gateway) y + /// a las peticiones hechas en o despues de ese instante. + /// + Task> GetRevisionTimelineAsync( + Guid tenantId, + string? repositoryUrl, + DateTime? sinceUtc, + CancellationToken cancellationToken = default); } diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs new file mode 100644 index 00000000..75ab0a74 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs @@ -0,0 +1,190 @@ +namespace Tracker.Domain.Integration.CoreEvaluationTransaction; + +/// +/// GT-615 — la proyeccion de veredicto sobre la linea de revisiones de un repositorio. +/// +/// El ledger core_evaluation_transactions ya guardaba repository_revision junto al +/// veredicto que el Core emitio para esa revision, pero nadie lo leia como serie: el unico acceso +/// era «todas las del tenant» o «una por id». Es decir, el sustrato del drift existia y no emitia +/// nada. Esta proyeccion es la lectura que faltaba: ordena las evaluaciones de UN repositorio, +/// les asigna el veredicto que el Core decidio, y emite cuando ese +/// veredicto CAMBIA entre revisiones consecutivas. +/// +public static class RepositoryRevisionTimeline +{ + /// Nombre del unico tipo de senal que esta proyeccion emite hoy. + public const string DriftDetected = "DriftDetected"; + + /// Clave bajo la que Complete() deja el veredicto del Core en ResponseData. + private const string DecisionKey = "decision"; + + private const string StatusCompleted = "completed"; + private const string StatusFailed = "failed"; + + /// Veredicto de una evaluacion cuyo estado no permite afirmar nada del Core. + public const string VerdictPending = "PENDING"; + + /// La evaluacion no llego a producirse (error de transporte/contrato): no es un veredicto. + public const string VerdictError = "ERROR"; + + /// Completada, pero sin decision registrada — un asiento antiguo o truncado. + public const string VerdictUnknown = "UNKNOWN"; + + /// + /// Proyecta las transacciones a entradas de linea temporal. La entrada debe venir YA ordenada + /// por la consulta (orden de revision = orden cronologico de peticion); aqui no se reordena + /// para no tapar un fallo del repositorio con un sort defensivo. + /// + public static IReadOnlyList Project( + IEnumerable transactions) + { + ArgumentNullException.ThrowIfNull(transactions); + + var entries = new List(); + var sequence = 0; + + foreach (var transaction in transactions) + { + var (verdict, decided) = ProjectVerdict(transaction); + entries.Add(new RepositoryRevisionEntry + { + Sequence = sequence++, + TransactionId = transaction.Id, + OperationId = transaction.OperationId, + RepositoryUrl = transaction.RepositoryUrl, + RepositoryRevision = transaction.RepositoryRevision, + Status = transaction.Status, + Verdict = verdict, + Decided = decided, + ObservedAtUtc = transaction.CompletedAtUtc ?? transaction.RequestedAtUtc + }); + } + + return entries; + } + + /// + /// Deriva el veredicto de una transaccion y si el Core llego a DECIDIR sobre ella. + /// decided=false para las pendientes y las fallidas: una caida del Core o un timeout no + /// son un veredicto sobre la arquitectura, y contarlas como tal convertiria cada incidencia de + /// transporte en un falso positivo de drift. + /// + public static (string Verdict, bool Decided) ProjectVerdict(CoreEvaluationTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (string.Equals(transaction.Status, StatusFailed, StringComparison.OrdinalIgnoreCase)) + { + return (VerdictError, false); + } + + if (!string.Equals(transaction.Status, StatusCompleted, StringComparison.OrdinalIgnoreCase)) + { + return (VerdictPending, false); + } + + if (transaction.ResponseData.TryGetValue(DecisionKey, out var decision)) + { + var text = decision?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return (text.Trim().ToUpperInvariant(), true); + } + } + + return (VerdictUnknown, false); + } + + /// + /// Recorre la linea por repositorio y emite una senal por cada cambio de veredicto entre + /// revisiones consecutivas DECIDIDAS. Las entradas no decididas (pendiente/error) se saltan + /// sin romper la cadena: entre un PASSED y el siguiente FAILED puede haber habido diez + /// reintentos fallidos y el drift sigue siendo uno solo, entre esas dos revisiones. + /// + public static IReadOnlyList DetectDrift(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var signals = new List(); + var previousByRepository = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + if (!entry.Decided) + { + continue; + } + + if (previousByRepository.TryGetValue(entry.RepositoryUrl, out var previous) + && !string.Equals(previous.Verdict, entry.Verdict, StringComparison.OrdinalIgnoreCase)) + { + signals.Add(new DriftSignal + { + Signal = DriftDetected, + RepositoryUrl = entry.RepositoryUrl, + FromRevision = previous.RepositoryRevision, + ToRevision = entry.RepositoryRevision, + FromVerdict = previous.Verdict, + ToVerdict = entry.Verdict, + FromTransactionId = previous.TransactionId, + ToTransactionId = entry.TransactionId, + // Una re-evaluacion de la MISMA revision que cambia de veredicto tambien es + // drift, pero de la regla, no del codigo. Distinguirlo aqui evita que quien + // lea la senal salga a buscar un commit que no existe. + SameRevision = string.Equals( + previous.RepositoryRevision, entry.RepositoryRevision, StringComparison.Ordinal), + DetectedAtUtc = entry.ObservedAtUtc + }); + } + + previousByRepository[entry.RepositoryUrl] = entry; + } + + return signals; + } +} + +/// Una revision evaluada, con el veredicto que el Core emitio sobre ella. +public sealed record RepositoryRevisionEntry +{ + /// Posicion en la linea (0 = la mas antigua del rango consultado). + public int Sequence { get; init; } + + public Guid TransactionId { get; init; } + public string OperationId { get; init; } = string.Empty; + public string RepositoryUrl { get; init; } = string.Empty; + public string RepositoryRevision { get; init; } = string.Empty; + + /// Estado del asiento (pending/completed/failed). + public string Status { get; init; } = string.Empty; + + /// Veredicto proyectado (PASSED/FAILED/SKIPPED/BLOCKED/…, o PENDING/ERROR/UNKNOWN). + public string Verdict { get; init; } = string.Empty; + + /// true solo si el Core llego a pronunciarse; ver . + public bool Decided { get; init; } + + public DateTime ObservedAtUtc { get; init; } +} + +/// +/// Senal derivada: el veredicto cambio entre dos revisiones consecutivas del mismo repositorio. +/// Se CALCULA sobre el ledger, no se almacena: el asiento de evaluacion sigue siendo el unico +/// sistema de registro, y una tabla paralela solo podria contradecirlo. +/// +public sealed record DriftSignal +{ + public string Signal { get; init; } = RepositoryRevisionTimeline.DriftDetected; + public string RepositoryUrl { get; init; } = string.Empty; + public string FromRevision { get; init; } = string.Empty; + public string ToRevision { get; init; } = string.Empty; + public string FromVerdict { get; init; } = string.Empty; + public string ToVerdict { get; init; } = string.Empty; + public Guid FromTransactionId { get; init; } + public Guid ToTransactionId { get; init; } + + /// El veredicto cambio sin cambiar de revision: drift de regla, no de codigo. + public bool SameRevision { get; init; } + + public DateTime DetectedAtUtc { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs index c23615c1..4849dfe4 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs @@ -189,4 +189,27 @@ public Task> GetByTenantAsync(Guid tena public Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default) => Task.FromResult(_items.Values.FirstOrDefault(x => x.OperationId == operationId)); + + /// + /// GT-615 — misma semantica que la implementacion Postgres: filtros opcionales combinables y + /// orden de revision (mas antigua primero, desempatando por id para que la serie sea total). + /// + public Task> GetRevisionTimelineAsync( + Guid tenantId, + string? repositoryUrl, + DateTime? sinceUtc, + CancellationToken cancellationToken = default) + { + var url = repositoryUrl?.Trim(); + + var items = _items.Values + .Where(x => x.TenantId == tenantId) + .Where(x => string.IsNullOrWhiteSpace(url) || x.RepositoryUrl == url) + .Where(x => sinceUtc is not { } since || x.RequestedAtUtc >= since) + .OrderBy(x => x.RequestedAtUtc) + .ThenBy(x => x.Id) + .ToArray(); + + return Task.FromResult>(items); + } } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs index 9e91793d..d07aed85 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs @@ -40,6 +40,53 @@ public async Task> GetByTenantAsync(Gui return records.Select(MapToDomain).ToList(); } + public async Task> GetRevisionTimelineAsync( + Guid tenantId, + string? repositoryUrl, + DateTime? sinceUtc, + CancellationToken cancellationToken = default) + { + var records = await ApplyRevisionTimelineQuery( + _context.CoreEvaluationTransactions, tenantId, repositoryUrl, sinceUtc) + .ToListAsync(cancellationToken); + return records.Select(MapToDomain).ToList(); + } + + /// + /// GT-615 — el filtro y el orden de la linea de revisiones, aislados como composicion sobre + /// . Se extrae del metodo para poder ejercitarlo sin Postgres (mismo + /// idioma que el mapper de ProviderConnection): el defecto que cierra este gap no es de + /// transporte sino de consulta, y una prueba que necesite base de datos se saltaria en CI y + /// dejaria el criterio de aceptacion sin demostrar. + /// + /// El desempate por Id no es cosmetico: dos evaluaciones del mismo repositorio pueden + /// compartir requested_at al milisegundo, y sin orden total la deteccion de drift + /// emitiria senales distintas en ejecuciones identicas. + /// + internal static IQueryable ApplyRevisionTimelineQuery( + IQueryable source, + Guid tenantId, + string? repositoryUrl, + DateTime? sinceUtc) + { + var query = source.Where(x => x.TenantId == tenantId && !x.IsDeleted); + + if (!string.IsNullOrWhiteSpace(repositoryUrl)) + { + var url = repositoryUrl.Trim(); + query = query.Where(x => x.RepositoryUrl == url); + } + + if (sinceUtc is { } since) + { + query = query.Where(x => x.RequestedAtUtc >= since); + } + + return query + .OrderBy(x => x.RequestedAtUtc) + .ThenBy(x => x.Id); + } + public async Task AddAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default) { await _context.CoreEvaluationTransactions.AddAsync(MapToRecord(aggregate), cancellationToken); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs index 968a3ad0..a269a5d7 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs @@ -1,5 +1,6 @@ using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetCoreEvaluationTransaction; +using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline; using Tracker.Domain.Integration.CoreEvaluationTransaction; namespace Tracker.Presentation.Endpoints.Integration; @@ -10,16 +11,56 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil { var group = app.MapGroup("/core-evaluation-transactions").WithTags("Integration"); + // GT-615 — el listado del ledger acepta ahora los dos filtros que no tenia: + // `repositoryUrl` y `since`. Sin ellos el unico acceso era «todo el tenant», asi que + // interrogar el historial de UN repositorio obligaba a descargarlo entero y filtrar en el + // cliente. Sin filtros la respuesta es identica a la de siempre (mas reciente primero). group.MapGet("/", async ( + string? repositoryUrl, + DateTime? since, ITrackerUserContext user, ICoreEvaluationTransactionRepository repository, CancellationToken ct) => { - var items = await repository.GetByTenantAsync(user.TenantId, ct); - return Results.Ok(items.Select(ToDto)); + if (string.IsNullOrWhiteSpace(repositoryUrl) && since is null) + { + var all = await repository.GetByTenantAsync(user.TenantId, ct); + return Results.Ok(all.Select(ToDto)); + } + + // La consulta filtrada devuelve orden de revision (ascendente); este listado es el + // ledger y su contrato es «lo mas reciente primero», asi que se invierte aqui en vez + // de duplicar la consulta con el orden contrario. + var items = await repository.GetRevisionTimelineAsync( + user.TenantId, repositoryUrl, NormalizeToUtc(since), ct); + return Results.Ok(items.Reverse().Select(ToDto)); }) .RequireTrackerPermission(TrackerPermissions.CoreTransactionRead) - .WithName("ListCoreEvaluationTransactions"); + .WithName("ListCoreEvaluationTransactions") + .Produces>(200); + + // GT-615 — la lectura que faltaba: el ledger guardaba `repository_revision` junto al + // veredicto y nadie lo leia como serie. Aqui la revision es el eje: orden de revision + // (mas antigua primero), veredicto proyectado por revision y una senal `DriftDetected` + // por cada cambio de veredicto entre revisiones consecutivas. + // + // Exige `tracker:drift:read` —el permiso de drift ya estaba declarado en el vocabulario y + // no lo pedia ninguna ruta— y no `core:transaction:read`: quien vigila la deriva + // arquitectonica no tiene por que poder leer el ledger de integracion entero. + group.MapGet("/revisions", async ( + string? repositoryUrl, + DateTime? since, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var timeline = await mediator.Send(new GetRepositoryRevisionTimelineQuery( + user.TenantId, repositoryUrl, NormalizeToUtc(since)), ct); + return Results.Ok(timeline); + }) + .RequireTrackerPermission(TrackerPermissions.DriftRead) + .WithName("GetRepositoryRevisionTimeline") + .Produces(200); group.MapGet("/{id:guid}", async ( Guid id, @@ -34,6 +75,19 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil .WithName("GetCoreEvaluationTransaction"); } + /// + /// `requested_at` se guarda en UTC. Un `since` que llegue con offset (o sin `Z`, que el binder + /// resuelve como hora local del servidor) compararia contra otra escala y recortaria la serie + /// por un margen que cambia con el huso del proceso. + /// + private static DateTime? NormalizeToUtc(DateTime? value) => value switch + { + null => null, + { Kind: DateTimeKind.Utc } utc => utc, + { Kind: DateTimeKind.Local } local => local.ToUniversalTime(), + var unspecified => DateTime.SpecifyKind(unspecified.Value, DateTimeKind.Utc) + }; + private static CoreEvaluationTransactionDto ToDto(CoreEvaluationTransaction transaction) { return new CoreEvaluationTransactionDto diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs new file mode 100644 index 00000000..e899a467 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs @@ -0,0 +1,134 @@ +using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline; +using Tracker.Domain.Integration.CoreEvaluationTransaction; +using Tracker.Infrastructure.Persistence.InMemory; + +namespace Tracker.Tests.Application.Integration; + +/// +/// GT-615 — el recorrido completo de la lectura que faltaba: consulta filtrada -> orden de +/// revision -> proyeccion de veredicto -> senal de deriva. Se ata contra el repositorio en memoria +/// (misma semantica de filtro y orden que la implementacion Postgres) para demostrar el criterio +/// de aceptacion sin depender de una base que en CI no existe. +/// +public class GetRepositoryRevisionTimelineQueryHandlerTests +{ + private const string RepoA = "https://github.com/org/repo-a"; + private const string RepoB = "https://github.com/org/repo-b"; + + private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc); + + private static CoreEvaluationTransaction Evaluacion( + Guid tenantId, string repositoryUrl, string revision, string decision, int minuto) + { + var momento = Origen.AddMinutes(minuto); + var txn = CoreEvaluationTransaction.Create( + tenantId, $"op-{revision}", repositoryUrl, revision, Guid.NewGuid()).Value!; + txn.Complete(new Dictionary { ["decision"] = decision, ["status"] = "COMPLETED" }); + txn.SetProps(txn.Props with { RequestedAtUtc = momento, CompletedAtUtc = momento }); + return txn; + } + + private static async Task Ledger( + params CoreEvaluationTransaction[] transacciones) + { + var repo = new InMemoryCoreEvaluationTransactionRepository(); + foreach (var t in transacciones) + { + await repo.AddAsync(t, CancellationToken.None); + } + + return repo; + } + + [Fact] + public async Task DevuelveLaLineaDeUnRepositorioConSuDeriva() + { + var tenant = Guid.NewGuid(); + var repo = await Ledger( + Evaluacion(tenant, RepoA, "a-1", "PASSED", 0), + Evaluacion(tenant, RepoB, "b-1", "FAILED", 5), + Evaluacion(tenant, RepoA, "a-2", "PASSED", 10), + Evaluacion(tenant, RepoA, "a-3", "FAILED", 20)); + + var handler = new GetRepositoryRevisionTimelineQueryHandler(repo); + + var resultado = await handler.Handle( + new GetRepositoryRevisionTimelineQuery(tenant, RepoA, null), CancellationToken.None); + + resultado.RepositoryUrl.Should().Be(RepoA); + resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2", "a-3"], + "la linea va en orden de revision y sin las evaluaciones de otro repositorio"); + + resultado.DriftSignals.Should().ContainSingle( + "el veredicto cambio una vez, entre a-2 y a-3"); + var senal = resultado.DriftSignals[0]; + senal.Signal.Should().Be(RepositoryRevisionTimeline.DriftDetected); + senal.FromRevision.Should().Be("a-2"); + senal.ToRevision.Should().Be("a-3"); + senal.FromVerdict.Should().Be("PASSED"); + senal.ToVerdict.Should().Be("FAILED"); + senal.DetectedAtUtc.Should().Be(Origen.AddMinutes(20)); + } + + [Fact] + public async Task ElCorteTemporalRecortaLaSerieYConEllaLaDeriva() + { + var tenant = Guid.NewGuid(); + var repo = await Ledger( + Evaluacion(tenant, RepoA, "a-1", "PASSED", 0), + Evaluacion(tenant, RepoA, "a-2", "FAILED", 10), + Evaluacion(tenant, RepoA, "a-3", "FAILED", 20)); + + var handler = new GetRepositoryRevisionTimelineQueryHandler(repo); + + var resultado = await handler.Handle( + new GetRepositoryRevisionTimelineQuery(tenant, RepoA, Origen.AddMinutes(10)), + CancellationToken.None); + + resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-2", "a-3"]); + resultado.DriftSignals.Should().BeEmpty( + "dentro de la ventana pedida el veredicto no cambia; el PASSED->FAILED quedo fuera y " + + "declararlo seria contar como reciente algo que la consulta excluyo"); + } + + [Fact] + public async Task SinFiltroDeRepositorio_CadaSerieSeEvaluaPorSeparado() + { + var tenant = Guid.NewGuid(); + var repo = await Ledger( + Evaluacion(tenant, RepoA, "a-1", "PASSED", 0), + Evaluacion(tenant, RepoB, "b-1", "PASSED", 5), + Evaluacion(tenant, RepoA, "a-2", "FAILED", 10), + Evaluacion(tenant, RepoB, "b-2", "PASSED", 15)); + + var handler = new GetRepositoryRevisionTimelineQueryHandler(repo); + + var resultado = await handler.Handle( + new GetRepositoryRevisionTimelineQuery(tenant, null, null), CancellationToken.None); + + resultado.RepositoryUrl.Should().BeNull(); + resultado.Revisions.Should().HaveCount(4); + resultado.DriftSignals.Should().ContainSingle(); + resultado.DriftSignals[0].RepositoryUrl.Should().Be(RepoA); + } + + [Fact] + public async Task NoDevuelveLaLineaDeOtroTenant() + { + var tenant = Guid.NewGuid(); + var ajeno = Guid.NewGuid(); + var repo = await Ledger( + Evaluacion(tenant, RepoA, "a-1", "PASSED", 0), + Evaluacion(ajeno, RepoA, "ajena-1", "FAILED", 10)); + + var handler = new GetRepositoryRevisionTimelineQueryHandler(repo); + + var resultado = await handler.Handle( + new GetRepositoryRevisionTimelineQuery(tenant, RepoA, null), CancellationToken.None); + + resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-1"]); + resultado.DriftSignals.Should().BeEmpty( + "mezclar tenants sobre la MISMA url de repositorio no solo filtraria datos ajenos: " + + "fabricaria una deriva inexistente"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs new file mode 100644 index 00000000..86e3d479 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs @@ -0,0 +1,192 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Tests.Domain.Integration; + +/// +/// GT-615 — el defecto que estas pruebas fijan: el ledger persistia `repository_revision` junto al +/// veredicto del Core y NO emitia nada. Habia sustrato para detectar deriva y ninguna lectura que +/// lo hiciera, asi que un repositorio podia pasar de PASSED a FAILED entre dos revisiones sin que +/// ninguna superficie lo dijera. +/// +/// La proyeccion es pura a proposito: la deteccion de drift es una regla de dominio, no una +/// consulta SQL, y aqui se demuestra sin base de datos ni host. +/// +public class RepositoryRevisionTimelineTests +{ + private const string RepoA = "https://github.com/org/repo-a"; + private const string RepoB = "https://github.com/org/repo-b"; + + private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc); + + /// + /// Un asiento del ledger tal como lo escriben `CoreEvaluationEndpoints` y + /// `ProductEndpoints.evaluate-architecture`: el veredicto viaja en `ResponseData["decision"]`. + /// + private static CoreEvaluationTransaction Evaluacion( + string repositoryUrl, string revision, string? decision, int minuto, string estado = "completed") + { + var momento = Origen.AddMinutes(minuto); + var txn = CoreEvaluationTransaction.Create( + Guid.NewGuid(), $"op-{revision}-{minuto}", repositoryUrl, revision, Guid.NewGuid()).Value!; + + if (estado == "completed") + { + txn.Complete(new Dictionary + { + ["decision"] = decision!, + ["status"] = "COMPLETED" + }); + } + else if (estado == "failed") + { + txn.Fail("CoreEvaluation.Unavailable", "el Core no respondio"); + } + + txn.SetProps(txn.Props with { RequestedAtUtc = momento, CompletedAtUtc = momento }); + return txn; + } + + [Fact] + public void CambioDeVeredictoEntreRevisionesConsecutivas_EmiteDriftDetected() + { + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-2", "PASSED", 10), + Evaluacion(RepoA, "rev-3", "FAILED", 20), + Evaluacion(RepoA, "rev-4", "FAILED", 30), + Evaluacion(RepoA, "rev-5", "PASSED", 40), + }; + + var entradas = RepositoryRevisionTimeline.Project(linea); + var senales = RepositoryRevisionTimeline.DetectDrift(entradas); + + senales.Should().HaveCount(2, + "hay dos cambios de veredicto (rev-2->rev-3 y rev-4->rev-5); una revision que repite " + + "veredicto no es deriva y emitirla convertiria la senal en ruido"); + + senales[0].Signal.Should().Be(RepositoryRevisionTimeline.DriftDetected); + senales[0].RepositoryUrl.Should().Be(RepoA); + senales[0].FromRevision.Should().Be("rev-2"); + senales[0].ToRevision.Should().Be("rev-3"); + senales[0].FromVerdict.Should().Be("PASSED"); + senales[0].ToVerdict.Should().Be("FAILED"); + senales[0].SameRevision.Should().BeFalse(); + senales[0].DetectedAtUtc.Should().Be(Origen.AddMinutes(20)); + + senales[1].FromRevision.Should().Be("rev-4"); + senales[1].ToRevision.Should().Be("rev-5"); + senales[1].FromVerdict.Should().Be("FAILED"); + senales[1].ToVerdict.Should().Be("PASSED"); + } + + [Fact] + public void VeredictoEstable_NoEmiteNada() + { + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-2", "PASSED", 10), + Evaluacion(RepoA, "rev-3", "PASSED", 20), + }; + + RepositoryRevisionTimeline + .DetectDrift(RepositoryRevisionTimeline.Project(linea)) + .Should().BeEmpty("sin cambio de veredicto no hay deriva que declarar"); + } + + [Fact] + public void LaDerivaNoCruzaRepositorios() + { + // Intercalados en el tiempo: si el recorrido no agrupara por repositorio, la alternancia + // A(PASSED) -> B(FAILED) -> A(PASSED) produciria dos senales inventadas. + var linea = new[] + { + Evaluacion(RepoA, "a-1", "PASSED", 0), + Evaluacion(RepoB, "b-1", "FAILED", 5), + Evaluacion(RepoA, "a-2", "PASSED", 10), + Evaluacion(RepoB, "b-2", "FAILED", 15), + }; + + RepositoryRevisionTimeline + .DetectDrift(RepositoryRevisionTimeline.Project(linea)) + .Should().BeEmpty("cada repositorio tiene su propia serie; mezclarlas fabrica deriva " + + "donde solo hay dos productos distintos evaluandose a la vez"); + } + + [Fact] + public void UnaEvaluacionFallida_NoCuentaComoVeredicto() + { + // Una caida del Core no es un veredicto sobre la arquitectura. Si contara, cada incidencia + // de transporte generaria DOS senales (PASSED->ERROR y ERROR->PASSED) y el tablero de + // deriva mediria la salud del Core en vez de la del repositorio. + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-2", null, 10, estado: "failed"), + Evaluacion(RepoA, "rev-3", "PASSED", 20), + }; + + var entradas = RepositoryRevisionTimeline.Project(linea); + + entradas[1].Verdict.Should().Be(RepositoryRevisionTimeline.VerdictError); + entradas[1].Decided.Should().BeFalse(); + + RepositoryRevisionTimeline.DetectDrift(entradas).Should().BeEmpty(); + } + + [Fact] + public void UnaEvaluacionPendiente_NoRompeLaCadena() + { + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-2", null, 10, estado: "pending"), + Evaluacion(RepoA, "rev-3", "FAILED", 20), + }; + + var entradas = RepositoryRevisionTimeline.Project(linea); + entradas[1].Verdict.Should().Be(RepositoryRevisionTimeline.VerdictPending); + + var senales = RepositoryRevisionTimeline.DetectDrift(entradas); + + senales.Should().ContainSingle("la revision sin decidir se salta, no corta la serie: el " + + "cambio real sigue siendo rev-1 -> rev-3"); + senales[0].FromRevision.Should().Be("rev-1"); + senales[0].ToRevision.Should().Be("rev-3"); + } + + [Fact] + public void ReevaluarLaMismaRevisionConOtroVeredicto_SeMarcaComoDerivaDeRegla() + { + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-1", "FAILED", 10), + }; + + var senales = RepositoryRevisionTimeline.DetectDrift(RepositoryRevisionTimeline.Project(linea)); + + senales.Should().ContainSingle(); + senales[0].SameRevision.Should().BeTrue( + "el codigo no cambio: lo que cambio fue la regla. Sin esta marca, quien lea la senal " + + "sale a buscar un commit que no existe"); + } + + [Fact] + public void LaProyeccionNumeraLaSerieYConservaElOrdenRecibido() + { + var linea = new[] + { + Evaluacion(RepoA, "rev-1", "PASSED", 0), + Evaluacion(RepoA, "rev-2", "FAILED", 10), + }; + + var entradas = RepositoryRevisionTimeline.Project(linea); + + entradas.Select(e => e.Sequence).Should().Equal([0, 1]); + entradas.Select(e => e.RepositoryRevision).Should().Equal(["rev-1", "rev-2"]); + entradas.Select(e => e.Verdict).Should().Equal(["PASSED", "FAILED"]); + entradas.Should().OnlyContain(e => e.Decided); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs new file mode 100644 index 00000000..0d5c6917 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs @@ -0,0 +1,133 @@ +using Tracker.Infrastructure.Persistence.Integration; +using Tracker.Infrastructure.Persistence.Integration.Entities; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// GT-615 — el otro medio defecto: el ledger solo se podia consultar «todo el tenant» o «una por +/// id». No habia filtro por repositorio, ni corte temporal, ni orden de revision. Esta prueba +/// ejercita la composicion de consulta REAL (la misma expresion que va a Postgres) sobre una lista +/// en memoria, siguiendo el idioma sin-base de `ProviderConnectionMapperTests`: el defecto es de +/// consulta, y una prueba que exigiera Postgres se saltaria en CI dejando el criterio sin demostrar. +/// +public class CoreEvaluationTransactionRevisionQueryTests +{ + private const string RepoA = "https://github.com/org/repo-a"; + private const string RepoB = "https://github.com/org/repo-b"; + + private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc); + + private static CoreEvaluationTransactionRecord Registro( + Guid tenantId, string repositoryUrl, string revision, int minuto, bool borrado = false) + => new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + OperationId = $"op-{revision}", + RepositoryUrl = repositoryUrl, + RepositoryRevision = revision, + Status = "completed", + RequestedAtUtc = Origen.AddMinutes(minuto), + CompletedAtUtc = Origen.AddMinutes(minuto), + IsDeleted = borrado + }; + + [Fact] + public void FiltraPorRepositorio_YDejaFueraLosDemas() + { + var tenant = Guid.NewGuid(); + var datos = new[] + { + Registro(tenant, RepoA, "a-1", 0), + Registro(tenant, RepoB, "b-1", 5), + Registro(tenant, RepoA, "a-2", 10), + }.AsQueryable(); + + var resultado = PostgreSqlCoreEvaluationTransactionRepository + .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null) + .ToList(); + + resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2"], + "sin este filtro interrogar el historial de UN repositorio obligaba a traerse el " + + "ledger entero del tenant y filtrar en el cliente"); + } + + [Fact] + public void FiltraPorSince_ConLimiteInclusivo() + { + var tenant = Guid.NewGuid(); + var datos = new[] + { + Registro(tenant, RepoA, "a-1", 0), + Registro(tenant, RepoA, "a-2", 10), + Registro(tenant, RepoA, "a-3", 20), + }.AsQueryable(); + + var resultado = PostgreSqlCoreEvaluationTransactionRepository + .ApplyRevisionTimelineQuery(datos, tenant, null, Origen.AddMinutes(10)) + .ToList(); + + resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-2", "a-3"], + "el corte es >= : excluir el instante exacto haria que dos consultas encadenadas por " + + "el ultimo `observedAt` se saltaran justo la revision de la frontera"); + } + + [Fact] + public void DevuelveLaSerieEnOrdenDeRevision_NoAlReves() + { + var tenant = Guid.NewGuid(); + var datos = new[] + { + Registro(tenant, RepoA, "a-3", 20), + Registro(tenant, RepoA, "a-1", 0), + Registro(tenant, RepoA, "a-2", 10), + }.AsQueryable(); + + var resultado = PostgreSqlCoreEvaluationTransactionRepository + .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null) + .ToList(); + + resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2", "a-3"], + "la deteccion de deriva lee la serie hacia adelante; con el orden del ledger (mas " + + "reciente primero) cada senal saldria invertida"); + } + + [Fact] + public void CombinaAmbosFiltros() + { + var tenant = Guid.NewGuid(); + var datos = new[] + { + Registro(tenant, RepoA, "a-1", 0), + Registro(tenant, RepoB, "b-1", 10), + Registro(tenant, RepoA, "a-2", 20), + }.AsQueryable(); + + var resultado = PostgreSqlCoreEvaluationTransactionRepository + .ApplyRevisionTimelineQuery(datos, tenant, RepoA, Origen.AddMinutes(10)) + .ToList(); + + resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-2"]); + } + + [Fact] + public void NoCruzaTenantsNiDevuelveBorrados() + { + var tenant = Guid.NewGuid(); + var otro = Guid.NewGuid(); + var datos = new[] + { + Registro(tenant, RepoA, "a-1", 0), + Registro(otro, RepoA, "ajeno", 5), + Registro(tenant, RepoA, "a-borrado", 10, borrado: true), + }.AsQueryable(); + + var resultado = PostgreSqlCoreEvaluationTransactionRepository + .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null) + .ToList(); + + resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1"], + "la superficie nueva no puede aflojar el aislamiento por tenant ni resucitar asientos " + + "borrados (T-044)"); + } +}