using System.Text.Json; using Omika.Core.Observability.External; using Omika.Core.Observability.Identity; using Omika.Core.Observability.Outbox; using Omika.Diagnostics.Import; using Omika.Diagnostics.Models; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tâche 13-B — Propagation ClientCode / ServerInstance / Environment dans le pipeline /// JSONL (transport uniquement, aucune résolution réelle) : /// /// B1–B5 : compatibilité ancien JSONL + round-trip des nouveaux champs. /// B6/B7 : le mapper (remote ET server via le store) conserve les 3 valeurs. /// B8/B24 : le ClientId mobile ne devient JAMAIS le ClientCode autoritaire. /// B9/B10 : StripIdentity épure les identités brutes et conserve l'ActorKey. /// B11/B12 : le ClientCode autoritaire fourni au store est conservé ; l'absence → null. /// /// Aucun PostgreSQL réel requis (fake + store fichier en temp, §51). /// public class DiagnosticJsonlContextTests : IDisposable { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly string _dir = CreateTempDirectory(); public void Dispose() => DeleteDirectory(_dir); // --------------------------- B1 : ancien JSONL sans nouveaux champs ---------- [Fact] public void B1_OldJsonlWithoutNewFields_DeserializesToNullContext() { const string line = """ {"receivedAtUtc":"2025-01-01T00:00:00+00:00","batchId":"11111111-1111-1111-1111-111111111111","ingestionTraceId":"22222222-2222-2222-2222-222222222222","event":{"eventId":"33333333-3333-3333-3333-333333333333","eventCode":"OMK-MOB-APP-001","timestampUtc":"2025-01-01T00:00:00+00:00","level":"Info"}} """; RemoteDiagnosticRecord? parsed = JsonSerializer.Deserialize(line, JsonOptions); Assert.NotNull(parsed); Assert.Null(parsed!.ClientCode); Assert.Null(parsed.ServerInstance); Assert.Null(parsed.Environment); } // --------------------------- B2/B3/B4/B5 : round-trip ------------------------ [Fact] public void B2_RecordWithClientCode_RoundTrips() { RemoteDiagnosticRecord record = DiagnosticTestData.Record( Guid.NewGuid(), "OMK-MOB-APP-001", clientCode: "CLIENT-TEST-A"); RemoteDiagnosticRecord? back = RoundTrip(record); Assert.Equal("CLIENT-TEST-A", back!.ClientCode); } [Fact] public void B3_RecordWithServerInstance_RoundTrips() { RemoteDiagnosticRecord record = DiagnosticTestData.Record( Guid.NewGuid(), "OMK-MOB-APP-001", serverInstance: "API-TEST-A1"); RemoteDiagnosticRecord? back = RoundTrip(record); Assert.Equal("API-TEST-A1", back!.ServerInstance); } [Fact] public void B4_RecordWithEnvironment_RoundTrips() { RemoteDiagnosticRecord record = DiagnosticTestData.Record( Guid.NewGuid(), "OMK-MOB-APP-001", environment: "Development"); RemoteDiagnosticRecord? back = RoundTrip(record); Assert.Equal("Development", back!.Environment); } [Fact] public void B5_RecordWithAllThreeFields_RoundTrips() { RemoteDiagnosticRecord record = DiagnosticTestData.Record( Guid.NewGuid(), "OMK-MOB-APP-001", clientCode: "CLIENT-TEST-A", serverInstance: "API-TEST-A1", environment: "Development"); RemoteDiagnosticRecord? back = RoundTrip(record); Assert.Equal("CLIENT-TEST-A", back!.ClientCode); Assert.Equal("API-TEST-A1", back!.ServerInstance); Assert.Equal("Development", back!.Environment); } // --------------------------- B6 : mapper remote ------------------------------ [Fact] public void B6_Mapper_RemoteRecord_PreservesThreeValues() { RemoteDiagnosticRecord record = DiagnosticTestData.Record( Guid.NewGuid(), "OMK-MOB-APP-001", clientCode: "CLIENT-TEST-A", serverInstance: "API-TEST-A1", environment: "Development"); DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile"); Assert.NotNull(mapped); Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode); Assert.Equal("API-TEST-A1", mapped.ServerInstance); Assert.Equal("Development", mapped.Environment); } // --------------------------- B7 : mapper server (via le store) --------------- [Fact] public async Task B7_ServerRecordThroughStoreAndMapper_PreservesValues() { // ServerDiagnosticRecord (privé) n'est jamais mappé directement : il est écrit // par le store puis relu comme RemoteDiagnosticRecord. Le cycle complet doit // conserver les 3 valeurs jusqu'au DiagnosticEventRecord. var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-TEST-A", ServerInstance = "API-TEST-A1", Environment = "Development" }; Guid eventId = Guid.NewGuid(); string content = await WriteAsync(NewEvent(eventId), DiagnosticActorKey.Empty, context); RemoteDiagnosticRecord? record = JsonSerializer.Deserialize(content, JsonOptions); Assert.NotNull(record); DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile"); Assert.NotNull(mapped); Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode); Assert.Equal("API-TEST-A1", mapped.ServerInstance); Assert.Equal("Development", mapped.Environment); } // --------------------------- B8 : ClientId mobile ≠ ClientCode ---------------- [Fact] public async Task B8_MobileClientId_NeverBecomesClientCodeAutomatically() { // Identité transport mobile brute : NE DOIT PAS devenir clientCode. DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT"); string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null); using JsonDocument doc = JsonDocument.Parse(content); // Contexte serveur absent → clientCode non écrit (WhenWritingNull), et le // ClientId mobile est épuré par StripIdentity : "FAUX-CLIENT" n'apparaît pas. Assert.False(doc.RootElement.TryGetProperty("clientCode", out _)); Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal); } // --------------------------- B9 : StripIdentity ------------------------------- [Fact] public async Task B9_StripIdentity_StillRemovesForbiddenIdentities() { DiagnosticEventDto evt = NewEvent( Guid.NewGuid(), deviceId: "device-1", clientId: "BATIK.01", userId: "user-1", collaborateurId: "42"); string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null); Assert.DoesNotContain("\"deviceId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"userId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"collaborateurId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("device-1", content, StringComparison.Ordinal); Assert.DoesNotContain("user-1", content, StringComparison.Ordinal); Assert.DoesNotContain("\"42\"", content, StringComparison.Ordinal); } // --------------------------- B10 : ActorKey conservé -------------------------- [Fact] public async Task B10_ActorKey_IsPreserved() { const string actorKeyValue = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; var actorKey = new DiagnosticActorKey(actorKeyValue, 1); string content = await WriteAsync(NewEvent(Guid.NewGuid()), actorKey, context: null); using JsonDocument doc = JsonDocument.Parse(content); Assert.Equal(actorKeyValue, doc.RootElement.GetProperty("actorKey").GetString()); Assert.Equal(1, doc.RootElement.GetProperty("actorKeyVersion").GetInt32()); } // --------------------------- B11 : ClientCode autoritaire conservé ------------ [Fact] public async Task B11_AuthoritativeClientCode_ProvidedToStore_IsPreserved() { var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" }; string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context); using JsonDocument doc = JsonDocument.Parse(content); Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString()); } // --------------------------- B12 : valeur absente → null ---------------------- [Fact] public async Task B12_AbsentContext_WritesNoContextFields_AndDeserializesToNull() { string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context: null); using (JsonDocument doc = JsonDocument.Parse(content)) { // Champs null omis (WhenWritingNull) : compatibilité ancien JSONL. Assert.False(doc.RootElement.TryGetProperty("clientCode", out _)); Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _)); Assert.False(doc.RootElement.TryGetProperty("environment", out _)); } RemoteDiagnosticRecord? record = JsonSerializer.Deserialize(content, JsonOptions); Assert.NotNull(record); Assert.Null(record!.ClientCode); Assert.Null(record.ServerInstance); Assert.Null(record.Environment); } // --------------------------- B24 : sécurité ---------------------------------- [Fact] public async Task B24_MobileClientId_WithAuthoritativeContext_UsesAuthoritativeCode() { // DTO mobile : identité transport falsifiable. DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT"); // Contexte serveur : ClientCode AUTORITAIRE (T13-C le fournira réellement). var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" }; string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context); using JsonDocument doc = JsonDocument.Parse(content); Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString()); Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal); Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase); } // --------------------------- Helpers ------------------------------------------ private static DiagnosticEventDto NewEvent( Guid eventId, string? deviceId = null, string? clientId = null, string? userId = null, string? collaborateurId = null) => new() { EventId = eventId, EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", DeviceId = deviceId, ClientId = clientId, UserId = userId, CollaborateurId = collaborateurId }; private static RemoteDiagnosticRecord? RoundTrip(RemoteDiagnosticRecord record) { string json = DiagnosticTestData.SerializeLine(record); return JsonSerializer.Deserialize(json, JsonOptions); } private async Task WriteAsync( DiagnosticEventDto evt, DiagnosticActorKey actorKey, DiagnosticDeploymentContext? context) { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); var batch = new DiagnosticBatchDto { Events = [evt] }; await store.StoreAsync(batch, actorKey, "trace-id", DateTimeOffset.UtcNow, CancellationToken.None, context); return ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); } private static string CreateTempDirectory() { string dir = Path.Combine(Path.GetTempPath(), "omika-jsonl-context-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } private static string ReadAllTextShared(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); return reader.ReadToEnd(); } private static void DeleteDirectory(string dir) { try { if (Directory.Exists(dir)) { Directory.Delete(dir, recursive: true); } } catch { // Best-effort. } } }