using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche T13-G — Tests multi-clients backend (G1-G24) : validation du comportement /// de filtrage/agrégation du contexte de déploiement sur un jeu de données synthétique /// représentatif du seed (DEMO-CLIENT-A / B / C, plusieurs ServerInstance, plusieurs /// environnements, erreurs multi-clients, NULL historiques, synchronisation /// API/Worker). Le tout sur (in-memory), /// miroir du repository PostgreSQL réel. /// public class DiagnosticMultiClientTests { private const string ClientA = "DEMO-CLIENT-A"; private const string ClientB = "DEMO-CLIENT-B"; private const string ClientC = "DEMO-CLIENT-C"; private const string ApiA = "API-A-01"; private const string WkrA = "WORKER-A-01"; private const string ApiB = "API-B-01"; private const string WkrB = "WORKER-B-01"; private const string ApiBStg = "API-B-STG-01"; private const string ApiCDev = "API-C-DEV-01"; private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset To = From.AddDays(30); /// Fabrique un enregistrement avec contexte, version et composant. private static DiagnosticEventRecord Ev( long id, string eventCode, string? client, string? server, string? environment, DateTimeOffset? ts = null, string level = "Info", string? component = null, string? errorCode = null, string? exceptionType = null, string? appVersion = null, string? platform = null, Guid? operationId = null) { DiagnosticEventRecord record = DiagnosticTestData.Event( Guid.NewGuid(), eventCode, ts ?? From, component: component, level: level, errorCode: errorCode); record.Id = id; record.ClientCode = client; record.ServerInstance = server; record.Environment = environment; record.ExceptionType = exceptionType; record.AppVersion = appVersion; record.Platform = platform; record.OperationId = operationId; return record; } /// /// Insère le jeu de données multi-clients représentatif du seed SQL (T13-G) : /// volumes décroissants A > B > C, erreur partagée A+B, erreur spécifique C, /// environnements Production/Staging/Development, NULL historiques, opérations de /// synchronisation API/Worker, versions par client. /// private static async Task InsertSeedAsync(FakeDiagnosticEventRepository repo) { Guid opA = Guid.NewGuid(); Guid opB = Guid.NewGuid(); await repo.InsertBatchAsync(new[] { // --- Volume de fond (A > B > C) --- Ev(1, "OMK-API-HTTP-001", ClientA, ApiA, "Production", appVersion: "1.4.0.0", platform: "api", component: "Omika.Api"), Ev(2, "OMK-API-HTTP-002", ClientA, ApiA, "Production", appVersion: "1.4.1.0", platform: "api", component: "Omika.Api"), Ev(3, "OMK-WKR-SYNC-001", ClientA, WkrA, "Production", appVersion: "1.4.0.0", platform: "worker", component: "Omika.Worker"), Ev(4, "OMK-API-HTTP-001", ClientB, ApiB, "Production", appVersion: "1.3.8.0", platform: "api", component: "Omika.Api"), Ev(5, "OMK-WKR-SYNC-001", ClientB, WkrB, "Production", appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker"), Ev(6, "OMK-API-HTTP-001", ClientB, ApiBStg, "Staging", appVersion: "1.4.1.0", platform: "api", component: "Omika.Api"), Ev(7, "OMK-API-HTTP-001", ClientC, ApiCDev, "Development", appVersion: "1.5.0.0", platform: "api", component: "Omika.Api"), // --- Erreur partagée A+B : même signature (TIMEOUT / TimeoutException / Omika.Api) --- Ev(8, "OMK-API-HTTP-003", ClientA, ApiA, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.0.0", platform: "api"), Ev(9, "OMK-API-HTTP-003", ClientA, ApiA, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.1.0", platform: "api"), Ev(10, "OMK-API-HTTP-003", ClientB, ApiB, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.3.8.0", platform: "api"), Ev(11, "OMK-API-HTTP-003", ClientB, ApiBStg, "Staging", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.1.0", platform: "api"), // --- Erreur spécifique à C (CRASH / OutOfMemoryException / Omika.Api) --- Ev(12, "OMK-API-HTTP-003", ClientC, ApiCDev, "Development", level: "Critical", component: "Omika.Api", errorCode: "CRASH", exceptionType: "System.OutOfMemoryException", appVersion: "1.5.0.0", platform: "api"), // --- NULL historiques (sans contexte) --- Ev(13, "OMK-MOB-APP-001", null, null, null, appVersion: "1.0.0.0", platform: "Android", component: "Omika.App"), Ev(14, "OMK-MOB-APP-002", null, null, null, level: "Warning", appVersion: "1.1.0.0", platform: "iOS", component: "Omika.App"), // --- Synchronisation API/Worker : même OperationId, même Client, serveurs différents --- Ev(15, "OMK-MOB-SYNC-001", ClientA, ApiA, "Production", operationId: opA, appVersion: "1.4.1.0", platform: "Android", component: "Omika.App"), Ev(16, "OMK-WKR-SYNC-001", ClientA, WkrA, "Production", operationId: opA, appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker"), Ev(17, "OMK-MOB-SYNC-001", ClientB, ApiB, "Production", operationId: opB, appVersion: "1.3.8.0", platform: "iOS", component: "Omika.App"), Ev(18, "OMK-WKR-SYNC-002", ClientB, WkrB, "Production", operationId: opB, level: "Error", errorCode: "SYNC_FAILED", appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker") }); return opA; } // --------------------------- G1 : filtre Client A -------------------------- [Fact] public async Task G1_ClientAFilter_ExcludesBAndC() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => Assert.Equal(ClientA, e.ClientCode)); Assert.DoesNotContain(result.Items, e => e.ClientCode is ClientB or ClientC); } // --------------------------- G2 : filtre Client B -------------------------- [Fact] public async Task G2_ClientBFilter_ExcludesAAndC() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => Assert.Equal(ClientB, e.ClientCode)); } // --------------------------- G3 : Client + Environment --------------------- [Fact] public async Task G3_ClientBStagingFilter_ReturnsOnlyThatCross() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB, Environment = "Staging" }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => { Assert.Equal(ClientB, e.ClientCode); Assert.Equal("Staging", e.Environment); }); } // --------------------------- G4 : Client + Server -------------------------- [Fact] public async Task G4_ClientAWorkerFilter_ReturnsOnlyWorkerEvents() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = WkrA }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => { Assert.Equal(ClientA, e.ClientCode); Assert.Equal(WkrA, e.ServerInstance); Assert.Equal("Omika.Worker", e.Component); }); } // --------------------------- G5 : trois filtres combinés ------------------- [Fact] public async Task G5_ThreeFiltersCombined_ReturnOnlyMatchingEvents() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB, ServerInstance = ApiBStg, Environment = "Staging" }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => { Assert.Equal(ClientB, e.ClientCode); Assert.Equal(ApiBStg, e.ServerInstance); Assert.Equal("Staging", e.Environment); }); } // --------------------------- G6 : NULL historiques ------------------------- [Fact] public async Task G6_HistoricalNull_VisibleWithoutFilter_ExcludedByClientFilter() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); // Sans filtre : les NULL sont visibles. DiagnosticQueryResult all = await repo.QueryAsync(new DiagnosticEventQuery()); Assert.Contains(all.Items, e => e.ClientCode is null && e.ServerInstance is null && e.Environment is null); // Avec un filtre Client : les NULL ne sont PAS inclus par erreur. DiagnosticQueryResult aOnly = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA }); Assert.DoesNotContain(aOnly.Items, e => e.ClientCode is null); } // --------------------------- G7/G8/G9 : groupes d'erreurs multi-clients ---- [Fact] public async Task G7_G8_G9_SharedErrorGroup_CountsPerClient() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticErrorGroupQuery allQuery = new() { FromUtc = From, ToUtc = To, ErrorsOnly = true }; IReadOnlyList all = await repo.GroupErrorsAsync(allQuery); // G7 : sans filtre, le groupe TIMEOUT agrège A + B (4 occurrences). DiagnosticErrorGroup timeoutGroup = Assert.Single(all, g => g.ErrorCode == "TIMEOUT"); Assert.Equal(4, timeoutGroup.OccurrenceCount); // G8 : filtre A → mêmes groupe, occurrences = A uniquement (2). IReadOnlyList aGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientA }); DiagnosticErrorGroup aTimeout = Assert.Single(aGroups, g => g.ErrorCode == "TIMEOUT"); Assert.Equal(2, aTimeout.OccurrenceCount); // G9 : filtre B → occurrences = B uniquement (2). IReadOnlyList bGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientB }); DiagnosticErrorGroup bTimeout = Assert.Single(bGroups, g => g.ErrorCode == "TIMEOUT"); Assert.Equal(2, bTimeout.OccurrenceCount); } // --------------------------- G10 : erreur spécifique C --------------------- [Fact] public async Task G10_CrashErrorGroup_IsAbsentForClientA() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); IReadOnlyList aGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientA }); Assert.DoesNotContain(aGroups, g => g.ErrorCode == "CRASH"); } // --------------------------- G11/G12/G13 : environnements ------------------ [Fact] public async Task G11_ProductionEnvironment_ExcludesStagingAndDevelopment() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Production" }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => Assert.Equal("Production", e.Environment)); Assert.DoesNotContain(result.Items, e => e.Environment is "Staging" or "Development"); } [Fact] public async Task G12_StagingEnvironment_ExcludesProduction() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Staging" }); Assert.NotEmpty(result.Items); Assert.All(result.Items, e => Assert.Equal("Staging", e.Environment)); } [Fact] public async Task G13_UnknownEnvironment_IsTechnicallySupported() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); // Aucune énumération fermée : une valeur inconnue est acceptée et retourne vide. DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Custom-Test" }); Assert.Empty(result.Items); Assert.Equal(0, result.TotalCount); } // --------------------------- G14/G15 : serveurs ---------------------------- [Fact] public async Task G14_ApiAndWorkerServers_AreSeparableWithinSameClient() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); DiagnosticQueryResult api = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = ApiA }); DiagnosticQueryResult worker = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = WkrA }); Assert.NotEmpty(api.Items); Assert.NotEmpty(worker.Items); Assert.All(api.Items, e => Assert.Equal(ApiA, e.ServerInstance)); Assert.All(worker.Items, e => Assert.Equal(WkrA, e.ServerInstance)); } [Fact] public async Task G15_ServersAreNotSharedBetweenClients() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); // Aucun serveur ne doit être partagé entre deux clients. string[] aServers = { ApiA, WkrA }; string[] bServers = { ApiB, WkrB, ApiBStg }; string[] cServers = { ApiCDev }; DiagnosticQueryResult a = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, PageSize = 100 }); Assert.All(a.Items, e => Assert.Contains(e.ServerInstance, aServers)); Assert.DoesNotContain(a.Items, e => bServers.Contains(e.ServerInstance) || cServers.Contains(e.ServerInstance)); DiagnosticQueryResult b = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB, PageSize = 100 }); Assert.All(b.Items, e => Assert.Contains(e.ServerInstance, bServers)); Assert.DoesNotContain(b.Items, e => aServers.Contains(e.ServerInstance) || cServers.Contains(e.ServerInstance)); } // --------------------------- G16/G17/G18 : synchronisation ----------------- [Fact] public async Task G16_SharedOperationId_KeepsClientEnvAndDistinctServers() { var repo = new FakeDiagnosticEventRepository(); Guid opA = await InsertSeedAsync(repo); IReadOnlyList events = await repo.GetByOperationIdAsync(opA); Assert.Equal(2, events.Count); DiagnosticEventRecord api = Assert.Single(events, e => e.Component == "Omika.App"); DiagnosticEventRecord worker = Assert.Single(events, e => e.Component == "Omika.Worker"); Assert.Equal(ClientA, api.ClientCode); Assert.Equal(ClientA, worker.ClientCode); Assert.Equal("Production", api.Environment); Assert.Equal("Production", worker.Environment); Assert.Equal(opA, api.OperationId); Assert.Equal(opA, worker.OperationId); Assert.Equal(ApiA, api.ServerInstance); Assert.Equal(WkrA, worker.ServerInstance); Assert.NotEqual(api.ServerInstance, worker.ServerInstance); Assert.NotEqual(api.Component, worker.Component); } [Fact] public async Task G17_ClientFilter_KeepsAllEventsOfSameClientOperation() { var repo = new FakeDiagnosticEventRepository(); Guid opA = await InsertSeedAsync(repo); DiagnosticQueryResult result = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, SyncOnly = true }); // Les deux composants de l'opération A (mobile + worker) sont conservés. Assert.Contains(result.Items, e => e.OperationId == opA && e.Component == "Omika.App"); Assert.Contains(result.Items, e => e.OperationId == opA && e.Component == "Omika.Worker"); } [Fact] public async Task G18_ClientFilter_ExcludesOtherClientOperation() { var repo = new FakeDiagnosticEventRepository(); Guid opA = await InsertSeedAsync(repo); DiagnosticQueryResult bSync = await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB, SyncOnly = true }); Assert.NotEmpty(bSync.Items); Assert.DoesNotContain(bSync.Items, e => e.OperationId == opA); Assert.All(bSync.Items, e => Assert.Equal(ClientB, e.ClientCode)); } // --------------------------- G19/G20 : versions ---------------------------- [Fact] public async Task G19_ClientAFilter_ReturnsOnlyClientAVersions() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); IReadOnlyList summaries = await repo.GetVersionSummariesAsync( new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ClientCode = ClientA }); Assert.NotEmpty(summaries); Assert.All(summaries, s => Assert.Contains(s.AppVersion, new[] { "1.4.0.0", "1.4.1.0" })); Assert.DoesNotContain(summaries, s => s.AppVersion is "1.3.8.0" or "1.5.0.0"); } [Fact] public async Task G20_ClientBStagingFilter_ReducesVersionSet() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); IReadOnlyList summaries = await repo.GetVersionSummariesAsync( new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ClientCode = ClientB, Environment = "Staging" }); // Seul 1.4.1.0 est observé chez B en Staging (1.3.8.0 est en Production uniquement). DiagnosticVersionSummary summary = Assert.Single(summaries); Assert.Equal("1.4.1.0", summary.AppVersion); } // --------------------------- G21/G22/G23 : options distinctes -------------- [Fact] public async Task G21_G22_G23_DistinctOptions_AreCompleteAndUnique() { var repo = new FakeDiagnosticEventRepository(); await InsertSeedAsync(repo); IReadOnlyList clients = await repo.GetFilterOptionsAsync( DiagnosticFilterDimension.ClientCode, From, DiagnosticFilterDefaults.ClientCodeOptionsLimit); IReadOnlyList servers = await repo.GetFilterOptionsAsync( DiagnosticFilterDimension.ServerInstance, From, DiagnosticFilterDefaults.ServerInstanceOptionsLimit); IReadOnlyList environments = await repo.GetFilterOptionsAsync( DiagnosticFilterDimension.Environment, From, DiagnosticFilterDefaults.EnvironmentOptionsLimit); // G21 : trois clients, sans doublon. Assert.Equal(new[] { ClientA, ClientB, ClientC }, clients); // G22 : les instances prévues, sans doublon (tri ordinal). Assert.Equal(new[] { ApiA, ApiB, ApiBStg, ApiCDev, WkrA, WkrB }, servers); // G23 : les environnements seed utilisés. Assert.Equal(new[] { "Development", "Production", "Staging" }, environments); } // --------------------------- G24 : limites bornées ------------------------- [Fact] public void G24_DimensionLimits_RemainBounded() { // Les limites de T13-F restent bornées et respectent la borne commune. Assert.InRange(DiagnosticFilterDefaults.ClientCodeOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit); Assert.InRange(DiagnosticFilterDefaults.ServerInstanceOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit); Assert.InRange(DiagnosticFilterDefaults.EnvironmentOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit); Assert.Equal(DiagnosticFilterDefaults.ClientCodeOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.ClientCode)); Assert.Equal(DiagnosticFilterDefaults.ServerInstanceOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.ServerInstance)); Assert.Equal(DiagnosticFilterDefaults.EnvironmentOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.Environment)); } }