using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche 3 — Page de détail + événements corrélés. Les tests s'appuient sur le /// (in-memory) ; l'implémentation /// PostgreSQL de production exécute les mêmes contrats (GetById paramétré, corrélation /// bornée par fenêtre temporelle + LIMIT, déduplication intrinsèque au SELECT). /// public class DiagnosticDetailTests { private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); private static DiagnosticEventRecord Ev( long id, DateTimeOffset ts, Guid? traceId = null, Guid? sessionId = null, Guid? operationId = null, string? actorKey = null, string? message = null, string eventCode = "ERR", string? component = null, string? exceptionType = null, string? stackTrace = null, string? metadataJson = null) => new() { Id = id, EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = ts, TraceId = traceId, SessionId = sessionId, OperationId = operationId, ActorKey = actorKey, Message = message, ExceptionType = exceptionType, StackTrace = stackTrace, MetadataJson = metadataJson, Component = component, Level = "Error", Priority = "P2", Source = DiagnosticSources.Mobile, IngestedAtUtc = DateTimeOffset.UtcNow }; private static DiagnosticCorrelationQuery Corr( DateTimeOffset ts, Guid? traceId = null, Guid? sessionId = null, Guid? operationId = null, string? actorKey = null, int limit = DiagnosticCorrelationDefaults.MaxLimit, DateTimeOffset? anchor = null) => new() { FromUtc = ts.AddHours(-DiagnosticCorrelationDefaults.DefaultWindowHours), ToUtc = ts.AddHours(DiagnosticCorrelationDefaults.DefaultWindowHours), TraceId = traceId, SessionId = sessionId, OperationId = operationId, ActorKey = actorKey, AnchorUtc = anchor, Limit = limit }; // ------------------------------------------------------- D1 : GetById existant ---- [Fact] public async Task GetById_Existing_ReturnsEvent() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(42, Base) }); DiagnosticEventRecord? ev = await repo.GetByIdAsync(42); Assert.NotNull(ev); Assert.Equal(42, ev.Id); } // ------------------------------------------------------- D2 : GetById absent ---- [Fact] public async Task GetById_Missing_ReturnsNull() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base) }); Assert.Null(await repo.GetByIdAsync(999)); } [Fact] public async Task GetById_NonPositive_ReturnsNull() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base) }); Assert.Null(await repo.GetByIdAsync(0)); Assert.Null(await repo.GetByIdAsync(-7)); } // ------------------------------------------------------- C1 : TraceId ---- [Fact] public async Task GetCorrelated_ByTraceId_ReturnsMatchingEvents() { Guid traceId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, traceId: traceId), Ev(2, Base.AddMinutes(1), traceId: traceId), Ev(3, Base.AddMinutes(2)) // autre trace → exclu }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId)); Assert.Equal(2, result.Count); Assert.All(result, e => Assert.Equal(traceId, e.TraceId)); } // ------------------------------------------------------- C2 : SessionId ---- [Fact] public async Task GetCorrelated_BySessionId_ReturnsMatchingEvents() { Guid sessionId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, sessionId: sessionId), Ev(2, Base.AddMinutes(1), sessionId: sessionId), Ev(3, Base.AddMinutes(2)) }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, sessionId: sessionId)); Assert.Equal(2, result.Count); Assert.All(result, e => Assert.Equal(sessionId, e.SessionId)); } // ------------------------------------------------------- C3 : OperationId ---- [Fact] public async Task GetCorrelated_ByOperationId_ReturnsMatchingEvents() { Guid operationId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, operationId: operationId), Ev(2, Base.AddMinutes(1), operationId: operationId), Ev(3, Base.AddMinutes(2)) }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, operationId: operationId)); Assert.Equal(2, result.Count); Assert.All(result, e => Assert.Equal(operationId, e.OperationId)); } // ------------------------------------------------------- C4 : ActorKey ---- [Fact] public async Task GetCorrelated_ByActorKey_ReturnsMatchingEvents() { const string actorKey = "ak-123456"; var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, actorKey: actorKey), Ev(2, Base.AddMinutes(1), actorKey: actorKey), Ev(3, Base.AddMinutes(2), actorKey: "other") }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, actorKey: actorKey)); Assert.Equal(2, result.Count); Assert.All(result, e => Assert.Equal(actorKey, e.ActorKey)); } // ------------------------------------------------------- C5 : fenêtre temporelle ---- [Fact] public async Task GetCorrelated_OutOfWindow_AreExcluded() { Guid traceId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, traceId: traceId), // dans la fenêtre Ev(2, Base.AddHours(-25), traceId: traceId), // avant la fenêtre (±24 h) Ev(3, Base.AddHours(25), traceId: traceId) // après la fenêtre }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId)); DiagnosticEventRecord item = Assert.Single(result); Assert.Equal(1, item.Id); } // ------------------------------------------------------- C6 : LIMIT ---- [Fact] public async Task GetCorrelated_RespectsLimit() { Guid traceId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); var events = new List(); for (int i = 0; i < 10; i++) { events.Add(Ev(i + 1, Base.AddMinutes(i), traceId: traceId)); } await repo.InsertBatchAsync(events); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId, limit: 3)); Assert.Equal(3, result.Count); } // ------------------------------------------------------- C7 : tri chronologique ---- [Fact] public async Task GetCorrelated_ReturnsChronologicalOrder() { Guid traceId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(3, Base.AddMinutes(2), traceId: traceId), Ev(1, Base, traceId: traceId), Ev(2, Base.AddMinutes(1), traceId: traceId) }); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId)); Assert.Equal(new long[] { 1, 2, 3 }, result.Select(e => e.Id).ToArray()); } // ------------------------------------- C7bis : sélection par proximité (ancre) ---- [Fact] public async Task GetCorrelated_WithAnchor_ReturnsEventsClosestToAnchor() { // Un acteur actif produit bien plus d'événements que la borne : sans ancre, la // sélection retenait les plus ANCIENS de la fenêtre, donc un contexte antérieur // à l'incident (voire sans rapport avec lui). const string actor = "acteur-ancre"; var repo = new FakeDiagnosticEventRepository(); var events = new List(); for (int i = 30; i >= 1; i--) { events.Add(Ev(100 + i, Base.AddMinutes(-i), actorKey: actor, message: $"avant{i:00}")); } for (int i = 1; i <= 3; i++) { events.Add(Ev(200 + i, Base.AddMinutes(i), actorKey: actor, message: $"apres{i:00}")); } await repo.InsertBatchAsync(events); IReadOnlyList result = await repo.GetCorrelatedAsync( Corr(Base, actorKey: actor, limit: 5, anchor: Base)); // Les 5 événements les plus PROCHES de l'ancre, des deux côtés (et non les // 5 plus anciens de la fenêtre) : la distance à l'erreur est le critère. Assert.Equal(5, result.Count); Assert.All( result, e => Assert.True( Math.Abs((e.TimestampUtc - Base).TotalMinutes) <= 3, $"Événement à {e.TimestampUtc:O} : trop éloigné de l'ancre.")); Assert.Contains(result, e => e.Message == "avant01"); Assert.Contains(result, e => e.Message == "apres02"); Assert.DoesNotContain(result, e => e.Message == "avant30"); Assert.DoesNotContain(result, e => e.Message == "apres03"); // Restitution chronologique malgré la sélection par proximité. Assert.Equal( result.OrderBy(e => e.TimestampUtc).Select(e => e.Id).ToArray(), result.Select(e => e.Id).ToArray()); } // ------------------------------------------------------- C8 : déduplication ---- [Fact] public async Task GetCorrelated_MatchingMultipleIdentifiers_AppearsOnce() { Guid traceId = Guid.NewGuid(); Guid sessionId = Guid.NewGuid(); var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(1, Base, traceId: traceId, sessionId: sessionId), // correspond aux DEUX identifiants Ev(2, Base.AddMinutes(1), traceId: traceId) // correspond au traceId seul }); IReadOnlyList result = await repo.GetCorrelatedAsync( Corr(Base, traceId: traceId, sessionId: sessionId)); Assert.Equal(2, result.Count); Assert.Equal(1, result.Count(e => e.Id == 1)); // l'événement 1 n'apparaît qu'une fois } // ------------------------------------------------------- C9 : aucun identifiant ---- [Fact] public async Task GetCorrelated_NoIdentifiers_ReturnsEmptyWithoutQuery() { // ThrowingDiagnosticRepository lèverait si une requête était tentée : le contrat // « aucun identifiant → aucune requête » est ainsi prouvé (résultat vide, pas d'exception). var repo = new ThrowingDiagnosticRepository(); IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base)); Assert.Empty(result); } // -------------------------------------------- validate fenêtre invalide ---- [Fact] public async Task GetCorrelated_InvalidWindow_Throws() { var repo = new FakeDiagnosticEventRepository(); var query = new DiagnosticCorrelationQuery { FromUtc = Base, ToUtc = Base, // FromUtc >= ToUtc → invalide TraceId = Guid.NewGuid() }; await Assert.ThrowsAsync(() => repo.GetCorrelatedAsync(query)); } }