Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user