using System.Text.Json; using Omika.Core.Observability.Identity; using Omika.Core.Observability.Outbox; namespace Omika.Tests.Observability.Outbox; /// Tests du stockage d'ingestion serveur (JSONL canonique, borné, fail-safe). public class RemoteDiagnosticIngestionStoreTests : IDisposable { private readonly string _dir = CreateTempDirectory(); public void Dispose() => DeleteDirectory(_dir); [Fact] public async Task Store_WritesCanonicalJsonLine_WithStableIds() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); Guid eventId = Guid.NewGuid(); Guid traceId = Guid.NewGuid(); Guid sessionId = Guid.NewGuid(); Guid operationId = Guid.NewGuid(); Guid batchId = Guid.NewGuid(); string ingestionTraceId = Guid.NewGuid().ToString("D"); var batch = new DiagnosticBatchDto { BatchId = batchId, Events = [ new DiagnosticEventDto { EventId = eventId, EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", TraceId = traceId, SessionId = sessionId, OperationId = operationId, Component = "télémétrie" } ] }; int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, ingestionTraceId, DateTimeOffset.UtcNow); Assert.Equal(1, accepted); string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")))); using JsonDocument document = JsonDocument.Parse(line); JsonElement root = document.RootElement; Assert.Equal(batchId.ToString(), root.GetProperty("batchId").GetString()); Assert.Equal(ingestionTraceId, root.GetProperty("ingestionTraceId").GetString()); JsonElement evt = root.GetProperty("event"); Assert.Equal(eventId.ToString(), evt.GetProperty("eventId").GetString()); Assert.Equal(traceId.ToString(), evt.GetProperty("traceId").GetString()); Assert.Equal(sessionId.ToString(), evt.GetProperty("sessionId").GetString()); Assert.Equal(operationId.ToString(), evt.GetProperty("operationId").GetString()); Assert.Equal("télémétrie", evt.GetProperty("component").GetString()); } [Fact] public async Task Store_ActiveReader_ReadsCompleteJsonLines() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); // Écrit 2 événements SANS disposer le store (le writer reste actif et ouvert). for (int i = 0; i < 2; i++) { await store.StoreAsync( new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", Component = $"event-{i}" } ] }, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); } // Lecture concurrente pendant que le writer est encore ouvert : chaque ligne // flushée doit être un JSON complet (aucune demi-ligne, aucune corruption). string[] lines = OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); Assert.Equal(2, lines.Length); for (int i = 0; i < lines.Length; i++) { using JsonDocument document = JsonDocument.Parse(lines[i]); Assert.True(document.RootElement.TryGetProperty("event", out JsonElement evt)); Assert.Equal($"event-{i}", evt.GetProperty("component").GetString()); } } [Fact] public async Task Store_NoDirectory_Throws() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = string.Empty }); await Assert.ThrowsAsync(() => store.StoreAsync(new DiagnosticBatchDto { Events = [new DiagnosticEventDto()] }, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow)); } [Fact] public async Task Store_Rotates_AndEnforcesMaxFiles() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir, MaxFileSizeBytes = 256, MaxFiles = 2, MinFileAgeHours = 0 // rétention immédiate (test du plafond borné historique) }); for (int i = 0; i < 50; i++) { var batch = new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", Component = new string('x', 80) } ] }; await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); } int fileCount = Directory.EnumerateFiles(_dir, "*.jsonl").Count(); Assert.True(fileCount <= 2, $"attendu ≤ 2 fichiers, trouvé {fileCount}"); } [Fact] public async Task Store_MinFileAge_ProtectsRecentFilesFromRetention() { // Phase 5C.2C : avec une protection d'âge, les fichiers récents ne sont JAMAIS // supprimés par EnforceMaxFiles (le Worker local peut ne pas les avoir encore // importés). Le spool dépasse temporairement MaxFiles au lieu de perdre des // diagnostics non importés (fail-open). var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir, MaxFileSizeBytes = 256, MaxFiles = 2, MinFileAgeHours = 24 // protection d'âge active }); for (int i = 0; i < 50; i++) { var batch = new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", Component = new string('x', 80) } ] }; await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); } // Tous les fichiers viennent d'être écrits (< 24 h) : AUCUN ne doit être supprimé. int fileCount = Directory.EnumerateFiles(_dir, "*.jsonl").Count(); Assert.True(fileCount > 2, $"la protection d'âge doit conserver les fichiers récents, trouvé {fileCount}"); Assert.Equal(0, store.DroppedFileCount); } [Fact] public async Task Store_DoesNotPersistRawRequestBodyOrUnknownFields() { // Le store n'écrit que le DTO typé (liste blanche). Un champ "password" reçu // dans le corps brut n'existe pas sur le DTO et n'est donc jamais persisté. var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); var batch = new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", Component = "ok" } ] }; await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); string content = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("authorization", content, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Store_PreservesScreenCodeAndFeatureCode() { // T18 : le JSONL canonique serveur doit conserver ScreenCode/FeatureCode // (dimensions d'usage) reçus du mobile. Régression : StripIdentity les // abandonnait (screen_code/feature_code NULL en PostgreSQL). var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); var batch = new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-UI-001", Component = "Navigation", ScreenCode = "SITE_DETAIL", FeatureCode = "REPORT_GENERATE" } ] }; await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); string line = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); using JsonDocument document = JsonDocument.Parse(line); JsonElement evt = document.RootElement.GetProperty("event"); Assert.Equal("SITE_DETAIL", evt.GetProperty("screenCode").GetString()); Assert.Equal("REPORT_GENERATE", evt.GetProperty("featureCode").GetString()); } [Fact] public async Task Store_NullScreenCodeAndFeatureCode_AreOmitted() { // Compatibilité : les payloads historiques sans usage fonctionnel continuent // de produire un JSONL SANS screenCode/featureCode (aucune invention, aucun null). var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); var batch = new DiagnosticBatchDto { Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", Component = "ok" } ] }; await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow); string line = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); using JsonDocument document = JsonDocument.Parse(line); JsonElement evt = document.RootElement.GetProperty("event"); Assert.False(evt.TryGetProperty("screenCode", out _)); Assert.False(evt.TryGetProperty("featureCode", out _)); } private static string CreateTempDirectory() { string dir = Path.Combine(Path.GetTempPath(), "omika-remote-diag-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } private static void DeleteDirectory(string dir) { try { if (Directory.Exists(dir)) { Directory.Delete(dir, recursive: true); } } catch { // Best-effort. } } }