using System.Text.Json; using Omika.Core.Observability; using Omika.Core.Observability.Diagnostic; namespace Omika.Tests.Observability.Diagnostic; public class LocalFileTelemetryExporterTests : IDisposable { private readonly string _dir = DiagnosticTestHarness.CreateTempDirectory(); [Fact] public async Task Export_WritesValidJsonLine() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir }); await exporter.ExportAsync([Event()]); await exporter.DisposeAsync(); string file = Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")); string[] lines = File.ReadAllLines(file); Assert.Single(lines); using JsonDocument doc = JsonDocument.Parse(lines[0]); Assert.Equal("OMK-MOB-APP-001", doc.RootElement.GetProperty("eventCode").GetString()); Assert.Equal("INFO", doc.RootElement.GetProperty("level").GetString()); Assert.Equal("P3", doc.RootElement.GetProperty("priority").GetString()); } [Fact] public async Task Export_PreservesFrenchAccents() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir }); var e = new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted, Component = "échec-créé-démarré-télémétrie" }; await exporter.ExportAsync([e]); await exporter.DisposeAsync(); string content = File.ReadAllText(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); Assert.Contains("échec", content); Assert.Contains("créé", content); Assert.Contains("démarré", content); Assert.Contains("télémétrie", content); } [Fact] public async Task Export_SerializesUsageAndDeviceDimensions() { // T17-B/T18 : le fichier local doit porter appBuild, dimensions d'écran et // usage fonctionnel (screenCode/featureCode), alignés sur le DTO transport. var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir }); var e = new TelemetryEvent { EventCode = TelemetryEventCodes.MobileScreenView, Component = "Navigation", AppBuild = "1", ScreenWidth = 1220, ScreenHeight = 2712, LogicalWidth = 375, LogicalHeight = 834, DisplayScale = 3.25, ScreenCode = DiagnosticScreenCodes.SiteDetail, FeatureCode = DiagnosticFeatureCodes.ReportGenerate }; await exporter.ExportAsync([e]); await exporter.DisposeAsync(); using JsonDocument doc = JsonDocument.Parse( File.ReadAllLines(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")))[0]); Assert.Equal("1", doc.RootElement.GetProperty("appBuild").GetString()); Assert.Equal(1220, doc.RootElement.GetProperty("screenWidth").GetInt32()); Assert.Equal(2712, doc.RootElement.GetProperty("screenHeight").GetInt32()); Assert.Equal(DiagnosticScreenCodes.SiteDetail, doc.RootElement.GetProperty("screenCode").GetString()); Assert.Equal(DiagnosticFeatureCodes.ReportGenerate, doc.RootElement.GetProperty("featureCode").GetString()); } [Fact] public async Task Export_SerializesExceptionDetail() { // Phase 6 : le fichier local (joint à l'email support) porte le détail // d'exception (message/type/stack trace), aligné sur le DTO transport. var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir }); var e = new DiagnosticEvent { EventCode = TelemetryEventCodes.MobileHandledException, Message = "Échec d'écriture", ExceptionType = "System.IO.IOException", StackTrace = "at Omika.App.Services.SyncEngine.Run()" }; await exporter.ExportAsync([e]); await exporter.DisposeAsync(); using JsonDocument doc = JsonDocument.Parse( File.ReadAllLines(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")))[0]); Assert.Equal("Échec d'écriture", doc.RootElement.GetProperty("message").GetString()); Assert.Equal("System.IO.IOException", doc.RootElement.GetProperty("exceptionType").GetString()); Assert.Equal("at Omika.App.Services.SyncEngine.Run()", doc.RootElement.GetProperty("stackTrace").GetString()); } [Fact] public async Task ActiveExporter_AllowsConcurrentRead_CompleteJsonLines() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir }); // L'exporteur reste ACTIF (fichier ouvert en écriture, FileShare.Read). await exporter.ExportAsync([Event(), Event()]); string file = Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")); // Lecture concurrente : FileShare.ReadWrite (le writer autorise la lecture). // File.ReadAllText (FileShare.Read) échouerait ici avec « used by another process ». using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); string content = reader.ReadToEnd(); // Le JSONL lu est exploitable : deux lignes JSON complètes, aucune demi-ligne. string[] lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(2, lines.Length); foreach (string line in lines) { using JsonDocument doc = JsonDocument.Parse(line); Assert.Equal("OMK-MOB-APP-001", doc.RootElement.GetProperty("eventCode").GetString()); } await exporter.DisposeAsync(); } [Fact] public async Task Rotation_WhenSizeExceeded() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir, MaxFileSizeBytes = 300, MaxFiles = 20 }); for (int i = 0; i < 20; i++) { await exporter.ExportAsync([Event()]); } await exporter.DisposeAsync(); Assert.True(Directory.EnumerateFiles(_dir, "*.jsonl").Count() >= 2); } [Fact] public async Task MaxFiles_IsEnforced() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = _dir, MaxFileSizeBytes = 100, MaxFiles = 3 }); for (int i = 0; i < 50; i++) { await exporter.ExportAsync([Event()]); } await exporter.DisposeAsync(); Assert.True(Directory.EnumerateFiles(_dir, "*.jsonl").Count() <= 3); } [Fact] public async Task InaccessibleDirectory_DoesNotThrow() { // Un FICHIER existant utilisé comme dossier → Directory.CreateDirectory échoue. string fileAsDirectory = Path.Combine(_dir, "not-a-dir"); File.WriteAllText(fileAsDirectory, "x"); var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = fileAsDirectory }); await exporter.ExportAsync([Event()]); await exporter.DisposeAsync(); } [Fact] public async Task EmptyDirectory_NoFileCreated() { var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = string.Empty }); await exporter.ExportAsync([Event()]); await exporter.DisposeAsync(); Assert.Empty(Directory.EnumerateFiles(_dir, "*.jsonl")); } private static OmikaEvent Event() => new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted }; public void Dispose() => DiagnosticTestHarness.DeleteDirectory(_dir); }