Files
Omika/tests/Omika.Tests/Observability/Outbox/RemoteDiagnosticIngestionStoreTests.cs
2026-10-23 18:46:50 +02:00

290 lines
11 KiB
C#

using System.Text.Json;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
namespace Omika.Tests.Observability.Outbox;
/// <summary>Tests du stockage d'ingestion serveur (JSONL canonique, borné, fail-safe).</summary>
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<InvalidOperationException>(() =>
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.
}
}
}