using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche 1 — Agrégations statistiques côté serveur. Les tests s'appuient sur le /// (in-memory) ; l'implémentation /// PostgreSQL de production exécute les mêmes contrats via GROUP BY / date_trunc. /// public class DiagnosticAggregationTests { 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 = "TEST", DateTimeOffset? ts = null, string? component = null, string? errorCode = null, string? appVersion = null, string level = "Info", string priority = "P2") => new() { EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = ts ?? From.AddMinutes(1), Component = component, ErrorCode = errorCode, AppVersion = appVersion, Level = level, Priority = priority, Source = DiagnosticSources.Mobile, IngestedAtUtc = DateTimeOffset.UtcNow }; // ---------------------------------------------------- Test 1 : EventCode ---- [Fact] public async Task CountGrouped_EventCode_ReturnsDescendingCounts() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("OMK-API-001", From.AddMinutes(1)), Ev("OMK-API-001", From.AddMinutes(2)), Ev("OMK-API-001", From.AddMinutes(3)), Ev("OMK-API-002", From.AddMinutes(4)) }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.EventCode, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Equal("OMK-API-001", result[0].Key); Assert.Equal(3, result[0].Count); Assert.Equal("OMK-API-002", result[1].Key); Assert.Equal(1, result[1].Count); } // ------------------------------------------------------ Test 2 : période ---- [Fact] public async Task CountGrouped_RespectsTimePeriod() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("BEFORE", From.AddMinutes(-5)), // avant FromUtc Ev("IN", From.AddMinutes(10)), // dans la période Ev("AFTER", To.AddMinutes(5)) // après ToUtc (borne exclusive) }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.EventCode, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); DiagnosticGroupCount group = Assert.Single(result); Assert.Equal("IN", group.Key); Assert.Equal(1, group.Count); } // -------------------------------------------------------- Test 3 : limit ---- [Fact] public async Task CountGrouped_RespectsLimit() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("A", From.AddMinutes(1)), Ev("A", From.AddMinutes(2)), Ev("B", From.AddMinutes(3)), Ev("B", From.AddMinutes(4)), Ev("C", From.AddMinutes(5)) }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.EventCode, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 }); Assert.Equal(2, result.Count); Assert.Equal("A", result[0].Key); Assert.Equal("B", result[1].Key); } // ----------------------------------------------------- Test 4 : NULL keys ---- [Fact] public async Task CountGrouped_NullErrorCode_DoesNotThrow() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("A", From.AddMinutes(1), errorCode: "E1"), Ev("B", From.AddMinutes(2)) // ErrorCode null }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.ErrorCode, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Contains(result, g => g.Key == "E1" && g.Count == 1); Assert.Contains(result, g => g.Key is null && g.Count == 1); } // ------------------------------------------------------ Test 5 : filtres ---- [Fact] public async Task CountGrouped_AppliesComponentFilter() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("A", From.AddMinutes(1), component: "Omika.Api"), Ev("A", From.AddMinutes(2), component: "Omika.Api"), Ev("A", From.AddMinutes(3), component: "Omika.Worker") }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.EventCode, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Component = "Omika.Api" }); DiagnosticGroupCount group = Assert.Single(result); Assert.Equal("A", group.Key); Assert.Equal(2, group.Count); } // ------------------------------------------------ Test 6 : bucket horaire ---- [Fact] public async Task CountByTimeBucket_Hour_ReturnsChronologicalBuckets() { var repo = new FakeDiagnosticEventRepository(); var baseTime = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero); await repo.InsertBatchAsync(new[] { Ev("A", baseTime), // 10:30 → bucket 10:00 Ev("B", baseTime.AddMinutes(15)), // 10:45 → bucket 10:00 Ev("C", baseTime.AddHours(1)) // 11:30 → bucket 11:00 }); IReadOnlyList result = await repo.CountByTimeBucketAsync( DiagnosticTimeBucket.Hour, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero), result[0].BucketUtc); Assert.Equal(2, result[0].Count); Assert.Equal(new DateTimeOffset(2025, 1, 1, 11, 0, 0, TimeSpan.Zero), result[1].BucketUtc); Assert.Equal(1, result[1].Count); } // ----------------------------------------------- Test 7 : bucket journalier -- [Fact] public async Task CountByTimeBucket_Day_ReturnsChronologicalBuckets() { var repo = new FakeDiagnosticEventRepository(); var day1 = new DateTimeOffset(2025, 1, 1, 9, 0, 0, TimeSpan.Zero); var day2 = new DateTimeOffset(2025, 1, 2, 9, 0, 0, TimeSpan.Zero); await repo.InsertBatchAsync(new[] { Ev("A", day1), Ev("B", day1.AddHours(5)), Ev("C", day2) }); IReadOnlyList result = await repo.CountByTimeBucketAsync( DiagnosticTimeBucket.Day, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), result[0].BucketUtc); Assert.Equal(2, result[0].Count); Assert.Equal(new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero), result[1].BucketUtc); Assert.Equal(1, result[1].Count); } // ---------------------------------------- Test 8 : validation de la période -- [Fact] public void AggregateQuery_Validate_RejectsMissingFrom() { Assert.Throws(() => new DiagnosticAggregateQuery { ToUtc = DateTimeOffset.UtcNow }.Validate()); } [Fact] public void AggregateQuery_Validate_RejectsMissingTo() { Assert.Throws(() => new DiagnosticAggregateQuery { FromUtc = DateTimeOffset.UtcNow }.Validate()); } [Fact] public void AggregateQuery_Validate_RejectsFromAfterTo() { DateTimeOffset t = DateTimeOffset.UtcNow; Assert.Throws(() => new DiagnosticAggregateQuery { FromUtc = t.AddMinutes(1), ToUtc = t }.Validate()); } [Fact] public void AggregateQuery_Validate_RejectsTooLongPeriod() { DateTimeOffset from = DateTimeOffset.UtcNow; DateTimeOffset to = from.AddDays(DiagnosticAggregateDefaults.MaxPeriodDays + 1); Assert.Throws(() => new DiagnosticAggregateQuery { FromUtc = from, ToUtc = to }.Validate()); } [Fact] public async Task CountGrouped_InvalidPeriod_Throws() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset t = DateTimeOffset.UtcNow; await Assert.ThrowsAsync(() => repo.CountGroupedAsync( DiagnosticGroupDimension.EventCode, new DiagnosticAggregateQuery { FromUtc = t, ToUtc = t })); } // ------------------------------------------- Dimensions complémentaires ----- [Fact] public async Task CountGrouped_AppVersion_ReturnsGroups() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("A", From.AddMinutes(1), appVersion: "1.3.1"), Ev("B", From.AddMinutes(2), appVersion: "1.3.1"), Ev("C", From.AddMinutes(3), appVersion: "1.3.2") }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.AppVersion, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Contains(result, g => g.Key == "1.3.1" && g.Count == 2); Assert.Contains(result, g => g.Key == "1.3.2" && g.Count == 1); } [Fact] public async Task CountGrouped_Component_ReturnsGroups() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("A", From.AddMinutes(1), component: "Omika.Api"), Ev("B", From.AddMinutes(2), component: "Omika.Api"), Ev("C", From.AddMinutes(3), component: "Omika.Diagnostics.Worker") }); IReadOnlyList result = await repo.CountGroupedAsync( DiagnosticGroupDimension.Component, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To }); Assert.Equal(2, result.Count); Assert.Contains(result, g => g.Key == "Omika.Api" && g.Count == 2); Assert.Contains(result, g => g.Key == "Omika.Diagnostics.Worker" && g.Count == 1); } [Fact] public void AggregateQuery_ClampsLimit() { var tooBig = new DiagnosticAggregateQuery { Limit = 100_000 }; Assert.Equal(DiagnosticAggregateDefaults.MaxLimit, tooBig.Limit); var tooSmall = new DiagnosticAggregateQuery { Limit = 0 }; Assert.Equal(1, tooSmall.Limit); var defaultQuery = new DiagnosticAggregateQuery(); Assert.Equal(DiagnosticAggregateDefaults.DefaultLimit, defaultQuery.Limit); } }