290 lines
11 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|