using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche 4 (extension) — Séries par client et classement des licences en erreur. /// Contrats couverts : CountByTimeBucketAndClientAsync (répartition par client, /// NULL/vides regroupés, ErrorsOnly) et CountLicensesByErrorsAsync (classement /// par nombre d'erreurs, licences manquantes exclues, limite). Tests sur le /// (in-memory) ; l'implémentation PostgreSQL /// exécute les mêmes contrats via GROUP BY date_trunc(...), NULLIF(TRIM(client_code),'') /// et GROUP BY NULLIF(TRIM(license_number),''). /// public class DiagnosticClientSeriesTests { 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( DateTimeOffset? ts = null, string? clientCode = null, string? licenseNumber = null, string level = "Info") => new() { EventId = Guid.NewGuid(), EventCode = "TEST", TimestampUtc = ts ?? From.AddMinutes(1), ClientCode = clientCode, LicenseNumber = licenseNumber, Level = level, Priority = "P2", Source = DiagnosticSources.Mobile, IngestedAtUtc = DateTimeOffset.UtcNow }; private static DiagnosticAggregateQuery All() => new() { FromUtc = From, ToUtc = To }; // --------------------------------------------- série par client : répartition ---- [Fact] public async Task CountByTimeBucketAndClient_SplitsByClient() { var repo = new FakeDiagnosticEventRepository(); var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero); await repo.InsertBatchAsync(new[] { Ev(t, clientCode: "BATIK.01"), Ev(t.AddMinutes(5), clientCode: "BATIK.01"), Ev(t.AddMinutes(10), clientCode: "AUTRE.02") }); IReadOnlyList result = await repo.CountByTimeBucketAndClientAsync( DiagnosticTimeBucket.Hour, All()); Assert.Equal(2, result.Count); Assert.All(result, r => Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero), r.BucketUtc)); Assert.Contains(result, r => r.ClientCode == "BATIK.01" && r.Count == 2); Assert.Contains(result, r => r.ClientCode == "AUTRE.02" && r.Count == 1); } // ---------------------------------------- série par client : client manquant ---- [Fact] public async Task CountByTimeBucketAndClient_NullAndBlankClients_GroupedUnderNull() { var repo = new FakeDiagnosticEventRepository(); var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero); await repo.InsertBatchAsync(new[] { Ev(t, clientCode: null), Ev(t.AddMinutes(5), clientCode: " "), // espaces → groupe « non identifié » Ev(t.AddMinutes(10), clientCode: "BATIK.01") }); IReadOnlyList result = await repo.CountByTimeBucketAndClientAsync( DiagnosticTimeBucket.Hour, All()); Assert.Equal(2, result.Count); Assert.Contains(result, r => r.ClientCode is null && r.Count == 2); Assert.Contains(result, r => r.ClientCode == "BATIK.01" && r.Count == 1); } // ------------------------------------------- série par client : ErrorsOnly ---- [Fact] public async Task CountByTimeBucketAndClient_ErrorsOnly_OnlyErrorLevels() { var repo = new FakeDiagnosticEventRepository(); var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero); await repo.InsertBatchAsync(new[] { Ev(t, clientCode: "A", level: "Error"), Ev(t.AddMinutes(5), clientCode: "A", level: "Info"), Ev(t.AddMinutes(10), clientCode: "B", level: "critical") }); IReadOnlyList result = await repo.CountByTimeBucketAndClientAsync( DiagnosticTimeBucket.Hour, new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true }); Assert.Equal(2, result.Count); Assert.Contains(result, r => r.ClientCode == "A" && r.Count == 1); Assert.Contains(result, r => r.ClientCode == "B" && r.Count == 1); } // ------------------------------------------ licences en erreur : classement ---- [Fact] public async Task CountLicensesByErrors_RanksByErrorCount() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(From.AddMinutes(1), licenseNumber: "L-1", level: "Error"), Ev(From.AddMinutes(2), licenseNumber: "L-1", level: "Critical"), Ev(From.AddMinutes(3), licenseNumber: "L-2", level: "Error"), Ev(From.AddMinutes(4), licenseNumber: "L-1", level: "Info"), // non-erreur → exclu Ev(From.AddMinutes(5), licenseNumber: "L-2", level: "Info") }); IReadOnlyList result = await repo.CountLicensesByErrorsAsync(All()); Assert.Equal(2, result.Count); Assert.Equal("L-1", result[0].LicenseNumber); Assert.Equal(2, result[0].ErrorCount); Assert.Equal("L-2", result[1].LicenseNumber); Assert.Equal(1, result[1].ErrorCount); } // ----------------------------------- licences en erreur : licences manquantes ---- [Fact] public async Task CountLicensesByErrors_ExcludesMissingLicenses() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(From.AddMinutes(1), licenseNumber: null, level: "Error"), Ev(From.AddMinutes(2), licenseNumber: " ", level: "Error"), Ev(From.AddMinutes(3), licenseNumber: "L-1", level: "Error") }); IReadOnlyList result = await repo.CountLicensesByErrorsAsync(All()); DiagnosticLicenseErrorCount row = Assert.Single(result); Assert.Equal("L-1", row.LicenseNumber); Assert.Equal(1, row.ErrorCount); } // ------------------------------------------- licences en erreur : limite ---- [Fact] public async Task CountLicensesByErrors_RespectsLimit() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev(From.AddMinutes(1), licenseNumber: "L-1", level: "Error"), Ev(From.AddMinutes(2), licenseNumber: "L-2", level: "Error"), Ev(From.AddMinutes(3), licenseNumber: "L-2", level: "Error"), Ev(From.AddMinutes(4), licenseNumber: "L-3", level: "Error") }); IReadOnlyList result = await repo.CountLicensesByErrorsAsync( new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 }); Assert.Equal(2, result.Count); Assert.Equal("L-2", result[0].LicenseNumber); Assert.Equal("L-1", result[1].LicenseNumber); // ex-aequo 1 erreur : ordre alphabétique } }