Files
Omika/tests/Omika.Diagnostics.Tests/DiagnosticMultiClientTests.cs
T
2026-10-23 18:46:50 +02:00

471 lines
22 KiB
C#

using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// 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 <see cref="FakeDiagnosticEventRepository"/> (in-memory),
/// miroir du repository PostgreSQL réel.
/// </summary>
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);
/// <summary>Fabrique un enregistrement avec contexte, version et composant.</summary>
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;
}
/// <summary>
/// Insère le jeu de données multi-clients représentatif du seed SQL (T13-G) :
/// volumes décroissants A &gt; B &gt; 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.
/// </summary>
private static async Task<Guid> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticErrorGroup> 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<DiagnosticErrorGroup> 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<DiagnosticErrorGroup> 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<DiagnosticErrorGroup> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> api =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = ApiA });
DiagnosticQueryResult<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticVersionSummary> 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<DiagnosticVersionSummary> 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<string> clients = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ClientCode, From, DiagnosticFilterDefaults.ClientCodeOptionsLimit);
IReadOnlyList<string> servers = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ServerInstance, From, DiagnosticFilterDefaults.ServerInstanceOptionsLimit);
IReadOnlyList<string> 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));
}
}