using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche 2 — Regroupement d'erreurs logiquement équivalentes. Les tests s'appuient sur /// le (in-memory) ; l'implémentation /// PostgreSQL de production exécute le même regroupement via GROUP BY serveur. /// public class DiagnosticErrorGroupTests { private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset To = new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero); private static DiagnosticEventRecord Ev( string eventCode = "ERR", string? exceptionType = "System.Exception", DateTimeOffset? ts = null, string? component = null, string? errorCode = null, string? appVersion = null, string? actorKey = null, string level = "Error", string priority = "P2") => new() { EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = ts ?? From.AddMinutes(1), Component = component, ErrorCode = errorCode, ExceptionType = exceptionType, Level = level, Priority = priority, AppVersion = appVersion, ActorKey = actorKey, Source = DiagnosticSources.Mobile, IngestedAtUtc = DateTimeOffset.UtcNow }; // --------------------------------- G1 : 500 équivalents → 1 groupe ---- [Fact] public async Task GroupErrors_EquivalentEvents_ProduceSingleGroup() { var repo = new FakeDiagnosticEventRepository(); var events = new List(); for (int i = 0; i < 500; i++) { events.Add(Ev( eventCode: "OMK-API-001", exceptionType: "System.InvalidOperationException", component: "Omika.Api", ts: From.AddSeconds(i))); } await repo.InsertBatchAsync(events); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(500, group.OccurrenceCount); Assert.Equal("OMK-API-001", group.EventCode); } // ------------------------------- G2 : ExceptionType différent → séparés ---- [Fact] public async Task GroupErrors_DifferentExceptionType_SeparateGroups() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: "System.A"), Ev(eventCode: "ERR", exceptionType: "System.B") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, groups.Count); } // --------------------------------- G3 : EventCode différent → séparés ---- [Fact] public async Task GroupErrors_DifferentEventCode_SeparateGroups() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "A", exceptionType: "System.E"), Ev(eventCode: "B", exceptionType: "System.E") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, groups.Count); } // ----------------------------------------- G4 : hors période → exclus ---- [Fact] public async Task GroupErrors_OutOfPeriod_AreExcluded() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(-5)), Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(5)), Ev(eventCode: "ERR", exceptionType: "System.E", ts: To.AddMinutes(5)) }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(1, group.OccurrenceCount); } // ------------------------------ G5 : NULL ExceptionType → déterministe ---- [Fact] public async Task GroupErrors_NullExceptionType_DoesNotThrow() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: null), Ev(eventCode: "ERR", exceptionType: null), Ev(eventCode: "ERR", exceptionType: "System.E") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, groups.Count); Assert.Contains(groups, g => g.ExceptionType is null && g.OccurrenceCount == 2); Assert.Contains(groups, g => g.ExceptionType == "System.E" && g.OccurrenceCount == 1); } // --------------------------------------- G6 : FirstSeen/LastSeen ---- [Fact] public async Task GroupErrors_FirstAndLastSeen_AreCorrect() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset first = From.AddMinutes(10); DateTimeOffset last = From.AddMinutes(40); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: "System.E", ts: first), Ev(eventCode: "ERR", exceptionType: "System.E", ts: last), Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(20)) }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(first, group.FirstSeenUtc); Assert.Equal(last, group.LastSeenUtc); } // ----------------------------------------------- G7 : limite respectée ---- [Fact] public async Task GroupErrors_RespectsLimit() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "A", exceptionType: "E"), Ev(eventCode: "B", exceptionType: "E"), Ev(eventCode: "C", exceptionType: "E") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, Limit = 2 }); Assert.Equal(2, groups.Count); } // ---------------------------------------- G8 : tri déterministe ---- [Fact] public async Task GroupErrors_Ties_SortedDeterministically() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "B", exceptionType: "E"), Ev(eventCode: "A", exceptionType: "E"), Ev(eventCode: "C", exceptionType: "E") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); // Tri par défaut : occurrence DESC puis clés croissantes (déterministe). Assert.Equal(new[] { "A", "B", "C" }, groups.Select(g => g.EventCode).ToArray()); } // ------------------------- Normalisation : casse insensible ---- [Fact] public async Task GroupErrors_CaseInsensitiveExceptionType_SameGroup() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: "System.InvalidOperationException"), Ev(eventCode: "ERR", exceptionType: "system.invalidoperationexception") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(2, group.OccurrenceCount); // Valeur représentative brute (MIN) conservée pour l'affichage. Assert.Equal("System.InvalidOperationException", group.ExceptionType); } // ------------------------- Normalisation : vide/espaces → NULL ---- [Fact] public async Task GroupErrors_EmptyExceptionType_TreatedAsNull() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: ""), Ev(eventCode: "ERR", exceptionType: " "), Ev(eventCode: "ERR", exceptionType: null) }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); // vide, espaces et NULL normalisent tous vers la même clé de groupe. DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(3, group.OccurrenceCount); } // ------------------------- Compteurs distincts (optionnels §13) ---- [Fact] public async Task GroupErrors_AppVersionAndActorCounts() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(eventCode: "ERR", exceptionType: "E", appVersion: "1.0", actorKey: "actor-1"), Ev(eventCode: "ERR", exceptionType: "E", appVersion: "1.0", actorKey: "actor-1"), Ev(eventCode: "ERR", exceptionType: "E", appVersion: "2.0", actorKey: "actor-2") }); IReadOnlyList groups = await repo.GroupErrorsAsync( new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To }); DiagnosticErrorGroup group = Assert.Single(groups); Assert.Equal(3, group.OccurrenceCount); Assert.Equal(2, group.AppVersionCount); // 1.0, 2.0 Assert.Equal(2, group.ActorCount); // actor-1, actor-2 } // ------------------------- Validation période obligatoire ---- [Theory] [InlineData(true, false)] [InlineData(false, true)] public async Task GroupErrors_Validate_RejectsMissingPeriod(bool missingFrom, bool missingTo) { var repo = new FakeDiagnosticEventRepository(); var query = new DiagnosticErrorGroupQuery { FromUtc = missingFrom ? null : From, ToUtc = missingTo ? null : To }; await Assert.ThrowsAsync(() => repo.GroupErrorsAsync(query)); } }