Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class BreadcrumbServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Capacity100_Add150_CountIs100_OldestDropped()
|
||||
{
|
||||
var service = new BreadcrumbService(100);
|
||||
|
||||
for (int i = 0; i < 150; i++)
|
||||
{
|
||||
service.Add(new Breadcrumb { Code = "B" + i });
|
||||
}
|
||||
|
||||
Assert.Equal(100, service.Count);
|
||||
IReadOnlyList<Breadcrumb> snapshot = service.Snapshot();
|
||||
Assert.Equal(100, snapshot.Count);
|
||||
// Les 50 plus anciens (B0..B49) sont supprimés.
|
||||
Assert.Equal("B50", snapshot[0].Code);
|
||||
Assert.Equal("B149", snapshot[^1].Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullBreadcrumb_DoesNotThrow()
|
||||
{
|
||||
var service = new BreadcrumbService(10);
|
||||
|
||||
service.Add(null!);
|
||||
|
||||
Assert.Equal(0, service.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentAdds_AreBounded_AndNeverThrow()
|
||||
{
|
||||
var service = new BreadcrumbService(100);
|
||||
|
||||
Parallel.For(0, 1000, i => service.Add(new Breadcrumb { Code = "C" + i }));
|
||||
|
||||
Assert.True(service.Count <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceContext_AddsTraceIdAutomatically()
|
||||
{
|
||||
var trace = new TraceContext();
|
||||
var service = new BreadcrumbService(10, trace);
|
||||
|
||||
Guid traceId;
|
||||
using (trace.BeginTrace())
|
||||
{
|
||||
traceId = trace.CurrentTraceId!.Value;
|
||||
service.Add(new Breadcrumb { Code = "NAVIGATION", Target = "RendezVousDetail" });
|
||||
}
|
||||
|
||||
Assert.Equal(traceId, service.Snapshot().Single().TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
/// <summary>
|
||||
/// Tests des helpers purs de capture d'appareil (T17-C). La logique commune aux
|
||||
/// providers Android/iOS (normalisation, bornage, orientation stable, chaîne
|
||||
/// DeviceInfo) est testée ici sans runtime mobile.
|
||||
/// </summary>
|
||||
public class DeviceInfoCaptureTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(" ", null)]
|
||||
[InlineData("Xiaomi", "Xiaomi")]
|
||||
[InlineData(" Xiaomi ", "Xiaomi")]
|
||||
public void Clean_TrimsAndNullsEmpty(string? input, string? expected)
|
||||
=> Assert.Equal(expected, DeviceInfoCapture.Clean(input));
|
||||
|
||||
[Fact]
|
||||
public void Clean_TruncatesToMaxLength()
|
||||
{
|
||||
string longValue = new string('a', DeviceInfoCapture.MaxFieldLength + 50);
|
||||
|
||||
string? result = DeviceInfoCapture.Clean(longValue);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(DeviceInfoCapture.MaxFieldLength, result!.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1080, 2400, 1080, 2400)] // portrait
|
||||
[InlineData(2400, 1080, 1080, 2400)] // paysage → même résultat (stable)
|
||||
[InlineData(1290, 2796, 1290, 2796)]
|
||||
[InlineData(0, 100, null, null)]
|
||||
[InlineData(-5, 100, null, null)]
|
||||
[InlineData(null, 100, null, null)]
|
||||
public void Normalize_IsOrientationStable(int? w, int? h, int? expectedWidth, int? expectedHeight)
|
||||
{
|
||||
(int Width, int Height)? result = DeviceInfoCapture.Normalize(w, h);
|
||||
|
||||
Assert.Equal(expectedWidth, result?.Width);
|
||||
Assert.Equal(expectedHeight, result?.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeScale_RejectsNonPositiveOrNaN()
|
||||
{
|
||||
Assert.Null(DeviceInfoCapture.NormalizeScale(null));
|
||||
Assert.Null(DeviceInfoCapture.NormalizeScale(0));
|
||||
Assert.Null(DeviceInfoCapture.NormalizeScale(-1));
|
||||
Assert.Null(DeviceInfoCapture.NormalizeScale(double.NaN));
|
||||
Assert.Equal(2.75, DeviceInfoCapture.NormalizeScale(2.75));
|
||||
Assert.Equal(3.0, DeviceInfoCapture.NormalizeScale(3.00004));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundDimension_ReturnsNullForInvalid()
|
||||
{
|
||||
Assert.Null(DeviceInfoCapture.RoundDimension(null));
|
||||
Assert.Null(DeviceInfoCapture.RoundDimension(0));
|
||||
Assert.Null(DeviceInfoCapture.RoundDimension(double.NaN));
|
||||
Assert.Equal(393, DeviceInfoCapture.RoundDimension(392.6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLicenseDeviceInfo_ContainsOnlyManufacturerAndModel()
|
||||
{
|
||||
var snapshot = new DeviceInfoSnapshot
|
||||
{
|
||||
DeviceManufacturer = "Apple",
|
||||
DeviceModel = "iPhone17,2"
|
||||
};
|
||||
|
||||
Assert.Equal("manufacturer=Apple;model=iPhone17,2", DeviceInfoCapture.BuildLicenseDeviceInfo(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLicenseDeviceInfo_ReturnsNullWhenNoHardware()
|
||||
=> Assert.Null(DeviceInfoCapture.BuildLicenseDeviceInfo(DeviceInfoSnapshot.Empty));
|
||||
|
||||
[Fact]
|
||||
public void BuildLicenseDeviceInfo_NeverContainsForbiddenIdentifiers()
|
||||
{
|
||||
var snapshot = new DeviceInfoSnapshot
|
||||
{
|
||||
DeviceManufacturer = "Samsung",
|
||||
DeviceModel = "SM-S928B"
|
||||
};
|
||||
|
||||
string? info = DeviceInfoCapture.BuildLicenseDeviceInfo(snapshot);
|
||||
|
||||
Assert.DoesNotContain("device", info, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("token", info, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("imei", info, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("serial", info, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Constants;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class DeviceInfoProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void PortableProvider_ReturnsUsefulSnapshot()
|
||||
{
|
||||
var provider = new PortableDeviceInfoProvider();
|
||||
DeviceInfoSnapshot snapshot = provider.GetSnapshot();
|
||||
|
||||
Assert.NotNull(snapshot.AppVersion);
|
||||
Assert.NotNull(snapshot.Platform);
|
||||
Assert.NotNull(snapshot.OperatingSystem);
|
||||
Assert.NotNull(snapshot.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ExposesNoForbiddenHardwareIdentifier()
|
||||
{
|
||||
string[] names = typeof(DeviceInfoSnapshot).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain(names, n =>
|
||||
n.Contains("Imei", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("Imsi", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("MacAddress", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("AdvertisingId", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("Serial", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("Sim", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("Fingerprint", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableProvider_OperatingSystemIsCanonical_NotOsDescription()
|
||||
{
|
||||
var provider = new PortableDeviceInfoProvider();
|
||||
DeviceInfoSnapshot snapshot = provider.GetSnapshot();
|
||||
|
||||
// Dimension analytique stable (Android/iOS/Windows/…) au lieu d'une chaîne
|
||||
// système longue et variable (ex. « Microsoft Windows 10.0.26200 »).
|
||||
Assert.Equal(snapshot.Platform, snapshot.OperatingSystem);
|
||||
Assert.DoesNotContain("Microsoft", snapshot.OperatingSystem, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableProvider_ScreenFieldsRemainNull()
|
||||
{
|
||||
// Le portable (Desktop) n'a pas d'écran mobile : les champs restent null.
|
||||
var provider = new PortableDeviceInfoProvider();
|
||||
DeviceInfoSnapshot snapshot = provider.GetSnapshot();
|
||||
|
||||
Assert.Null(snapshot.ScreenWidth);
|
||||
Assert.Null(snapshot.ScreenHeight);
|
||||
Assert.Null(snapshot.LogicalWidth);
|
||||
Assert.Null(snapshot.LogicalHeight);
|
||||
Assert.Null(snapshot.DisplayScale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableProvider_AppVersionIsNotHardcoded()
|
||||
{
|
||||
var provider = new PortableDeviceInfoProvider();
|
||||
DeviceInfoSnapshot snapshot = provider.GetSnapshot();
|
||||
|
||||
// Source réelle (assembly InformationalVersion) avec repli sur
|
||||
// ApplicationConstants.Version — plus une valeur « 1.0.0.0 » codée en dur.
|
||||
Assert.NotNull(snapshot.AppVersion);
|
||||
Assert.Equal(ApplicationConstants.Version, snapshot.AppVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingProvider_HostFallsBackToEmpty_NoThrow()
|
||||
{
|
||||
var host = new OmikaLocalDiagnostics(
|
||||
new LocalDiagnosticOptions { Directory = string.Empty },
|
||||
deviceInfoProvider: new ThrowingDeviceInfoProvider());
|
||||
|
||||
Assert.Same(DeviceInfoSnapshot.Empty, host.DeviceInfo);
|
||||
}
|
||||
|
||||
private sealed class ThrowingDeviceInfoProvider : IDeviceInfoProvider
|
||||
{
|
||||
public DeviceInfoSnapshot GetSnapshot() => throw new InvalidOperationException("provider défaillant (test)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle qu'aucune donnée sensible (mot de passe, token, identifiant matériel…)
|
||||
/// n'est exposée par le modèle d'événement ni écrite par l'exporteur local.
|
||||
/// Objectif : prévenir une future régression générée par IA.
|
||||
/// </summary>
|
||||
public class DiagnosticDataSafetyTests
|
||||
{
|
||||
private static readonly string[] ForbiddenNames =
|
||||
[
|
||||
"Password", "Token", "Authorization", "EmailBody", "ConnectionString",
|
||||
"IMEI", "MACAddress", "AdvertisingId", "IMSI", "SerialNumber", "Fingerprint"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void OmikaEvent_ExposesNoSensitiveProperty()
|
||||
{
|
||||
string[] names = typeof(OmikaEvent).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
foreach (string forbidden in ForbiddenNames)
|
||||
{
|
||||
Assert.DoesNotContain(names, n => n.Contains(forbidden, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceInfoSnapshot_ExposesNoSensitiveProperty()
|
||||
{
|
||||
string[] names = typeof(DeviceInfoSnapshot).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
foreach (string forbidden in ForbiddenNames)
|
||||
{
|
||||
Assert.DoesNotContain(names, n => n.Contains(forbidden, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StandardEvent_JsonContainsNoSensitiveKey()
|
||||
{
|
||||
string dir = DiagnosticTestHarness.CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var exporter = new LocalFileTelemetryExporter(new LocalDiagnosticOptions { Directory = dir });
|
||||
await exporter.ExportAsync([new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Component = "test"
|
||||
}]);
|
||||
await exporter.DisposeAsync();
|
||||
|
||||
string json = File.ReadAllText(Assert.Single(Directory.EnumerateFiles(dir, "*.jsonl")));
|
||||
|
||||
foreach (string forbidden in ForbiddenNames)
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
DiagnosticTestHarness.DeleteDirectory(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class DiagnosticSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void SessionId_IsNotEmpty()
|
||||
{
|
||||
Assert.NotEqual(Guid.Empty, new DiagnosticSession().SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameInstance_SameSessionId()
|
||||
{
|
||||
var session = new DiagnosticSession();
|
||||
|
||||
Assert.Equal(session.SessionId, session.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSession_NewSessionId()
|
||||
{
|
||||
Assert.NotEqual(new DiagnosticSession().SessionId, new DiagnosticSession().SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAtUtc_IsRecent()
|
||||
{
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow.AddSeconds(-2);
|
||||
var session = new DiagnosticSession();
|
||||
|
||||
Assert.InRange(session.StartedAtUtc, before, DateTimeOffset.UtcNow.AddSeconds(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
/// <summary>Fabrique de test partagée pour le diagnostic local (Phase 2).</summary>
|
||||
internal static class DiagnosticTestHarness
|
||||
{
|
||||
public static string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public static void DeleteDirectory(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 3000)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition() && DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
Assert.True(condition(), "La condition n'a pas été atteinte dans le délai imparti.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Diagnostics;
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class OmikaLocalDiagnosticsTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = DiagnosticTestHarness.CreateTempDirectory();
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_EventWrittenWithSessionContext()
|
||||
{
|
||||
var host = new OmikaLocalDiagnostics(new LocalDiagnosticOptions { Directory = _dir });
|
||||
host.Start();
|
||||
|
||||
host.Telemetry.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
|
||||
await DiagnosticTestHarness.WaitUntilAsync(() => host.Queue.Count == 0);
|
||||
await host.StopAsync();
|
||||
|
||||
string content = File.ReadAllText(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||||
Assert.Contains(host.Session.SessionId.ToString("N"), content);
|
||||
Assert.Contains("\"sessionId\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TelemetryDisabled_NoFileCreated()
|
||||
{
|
||||
var host = new OmikaLocalDiagnostics(
|
||||
new LocalDiagnosticOptions { Directory = _dir },
|
||||
new ObservabilityOptions { Enabled = false });
|
||||
host.Start();
|
||||
|
||||
host.Telemetry.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
|
||||
await Task.Delay(150);
|
||||
await host.StopAsync();
|
||||
|
||||
Assert.Empty(Directory.EnumerateFiles(_dir, "*.jsonl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_IsBounded_AndNeverThrows()
|
||||
{
|
||||
var host = new OmikaLocalDiagnostics(new LocalDiagnosticOptions
|
||||
{
|
||||
Directory = _dir,
|
||||
FlushTimeout = TimeSpan.FromMilliseconds(100)
|
||||
});
|
||||
host.Start();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
host.Telemetry.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await host.StopAsync();
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saturation_100kEvents_Bounded_NoException()
|
||||
{
|
||||
var host = new OmikaLocalDiagnostics(new LocalDiagnosticOptions { Directory = _dir });
|
||||
host.Start();
|
||||
|
||||
for (int i = 0; i < 100_000; i++)
|
||||
{
|
||||
host.Telemetry.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
Assert.True(Directory.EnumerateFiles(_dir, "*.jsonl").Count() <= 5);
|
||||
}
|
||||
|
||||
public void Dispose() => DiagnosticTestHarness.DeleteDirectory(_dir);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class TelemetryContextEnricherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Track_EnrichesSessionTraceAndDevice()
|
||||
{
|
||||
var (enricher, capturing, context, trace) = Build();
|
||||
Guid traceId;
|
||||
using (trace.BeginTrace())
|
||||
{
|
||||
traceId = trace.CurrentTraceId!.Value;
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
}
|
||||
|
||||
OmikaEvent captured = capturing.LastEvent!;
|
||||
Assert.Equal(context.SessionId, captured.SessionId);
|
||||
Assert.Equal(traceId, captured.TraceId);
|
||||
Assert.Equal("1.2.3", captured.AppVersion);
|
||||
Assert.Equal("TestPlatform", captured.Platform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_DoesNotOverrideExplicitValues()
|
||||
{
|
||||
var (enricher, capturing, _, _) = Build();
|
||||
Guid explicitSession = Guid.NewGuid();
|
||||
Guid explicitTrace = Guid.NewGuid();
|
||||
|
||||
enricher.Track(new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
SessionId = explicitSession,
|
||||
TraceId = explicitTrace
|
||||
});
|
||||
|
||||
Assert.Equal(explicitSession, capturing.LastEvent!.SessionId);
|
||||
Assert.Equal(explicitTrace, capturing.LastEvent!.TraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_EnrichesDiagnosticEvent()
|
||||
{
|
||||
var (enricher, capturing, _, _) = Build();
|
||||
|
||||
enricher.Log(new DiagnosticEvent { EventCode = TelemetryEventCodes.ApiRequestFailed });
|
||||
|
||||
Assert.Equal(TelemetryEventCodes.ApiRequestFailed, capturing.LastEvent!.EventCode);
|
||||
Assert.NotNull(capturing.LastEvent!.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichesOptionalIdentity_WhenSet()
|
||||
{
|
||||
var (enricher, capturing, context, _) = Build();
|
||||
context.DeviceId = "device-123";
|
||||
context.ClientId = "client-456";
|
||||
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
|
||||
Assert.Equal("device-123", capturing.LastEvent!.DeviceId);
|
||||
Assert.Equal("client-456", capturing.LastEvent!.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_EnrichesScreenCodeFromContext()
|
||||
{
|
||||
var (enricher, capturing, context, _) = Build();
|
||||
context.ScreenCode = Omika.Core.Observability.DiagnosticScreenCodes.SiteDetail;
|
||||
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, capturing.LastEvent!.ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_DoesNotOverrideExplicitScreenCode()
|
||||
{
|
||||
var (enricher, capturing, context, _) = Build();
|
||||
context.ScreenCode = DiagnosticScreenCodes.Agenda;
|
||||
|
||||
enricher.Track(new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileScreenView,
|
||||
ScreenCode = DiagnosticScreenCodes.SiteDetail
|
||||
});
|
||||
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, capturing.LastEvent!.ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScreenCode_DoesNotLeakIntoPreExistingBackgroundFlow()
|
||||
{
|
||||
var session = new DiagnosticSession();
|
||||
var context = new DiagnosticContext(session, new DeviceInfoSnapshot());
|
||||
|
||||
// Boucle background lancée AVANT toute navigation : son ExecutionContext est
|
||||
// capturé à null, puis reste null malgré la navigation sur le fil principal (§64).
|
||||
string? seenByBackground = null;
|
||||
Task background = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(20);
|
||||
seenByBackground = context.ScreenCode;
|
||||
});
|
||||
|
||||
context.ScreenCode = DiagnosticScreenCodes.Agenda;
|
||||
|
||||
await background;
|
||||
Assert.Null(seenByBackground);
|
||||
Assert.Equal(DiagnosticScreenCodes.Agenda, context.ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_EnrichesAppBuildAndScreenDimensions()
|
||||
{
|
||||
var session = new DiagnosticSession();
|
||||
var deviceInfo = new DeviceInfoSnapshot
|
||||
{
|
||||
AppVersion = "1.4.1",
|
||||
AppBuild = "10401",
|
||||
Platform = "Android",
|
||||
OperatingSystem = "Android",
|
||||
OperatingSystemVersion = "16",
|
||||
DeviceManufacturer = "Xiaomi",
|
||||
DeviceModel = "23113RKC6G",
|
||||
ScreenWidth = 1080,
|
||||
ScreenHeight = 2400,
|
||||
LogicalWidth = 393,
|
||||
LogicalHeight = 873,
|
||||
DisplayScale = 2.75
|
||||
};
|
||||
var context = new DiagnosticContext(session, deviceInfo);
|
||||
var trace = new TraceContext();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var enricher = new TelemetryContextEnricher(capturing, context, trace);
|
||||
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
|
||||
OmikaEvent captured = capturing.LastEvent!;
|
||||
Assert.Equal("10401", captured.AppBuild);
|
||||
Assert.Equal(1080, captured.ScreenWidth);
|
||||
Assert.Equal(2400, captured.ScreenHeight);
|
||||
Assert.Equal(393, captured.LogicalWidth);
|
||||
Assert.Equal(873, captured.LogicalHeight);
|
||||
Assert.Equal(2.75, captured.DisplayScale);
|
||||
Assert.Equal("Xiaomi", captured.DeviceManufacturer);
|
||||
Assert.Equal("23113RKC6G", captured.DeviceModel);
|
||||
}
|
||||
|
||||
private static (TelemetryContextEnricher Enricher, CapturingTelemetry Capturing, DiagnosticContext Context, TraceContext Trace) Build()
|
||||
{
|
||||
var session = new DiagnosticSession();
|
||||
var deviceInfo = new DeviceInfoSnapshot { AppVersion = "1.2.3", Platform = "TestPlatform" };
|
||||
var context = new DiagnosticContext(session, deviceInfo);
|
||||
var trace = new TraceContext();
|
||||
var capturing = new CapturingTelemetry();
|
||||
return (new TelemetryContextEnricher(capturing, context, trace), capturing, context, trace);
|
||||
}
|
||||
|
||||
private sealed class CapturingTelemetry : IOmikaTelemetry
|
||||
{
|
||||
public OmikaEvent? LastEvent { get; private set; }
|
||||
|
||||
public void Track(TelemetryEvent telemetryEvent) => LastEvent = telemetryEvent;
|
||||
|
||||
public void Log(DiagnosticEvent diagnosticEvent) => LastEvent = diagnosticEvent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Diagnostic;
|
||||
|
||||
public class TraceContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void BeginTrace_SetsCurrentTraceId_AndDisposeRestores()
|
||||
{
|
||||
var context = new TraceContext();
|
||||
Assert.Null(context.CurrentTraceId);
|
||||
|
||||
using (context.BeginTrace())
|
||||
{
|
||||
Assert.NotNull(context.CurrentTraceId);
|
||||
Assert.NotEqual(Guid.Empty, context.CurrentTraceId!.Value);
|
||||
}
|
||||
|
||||
Assert.Null(context.CurrentTraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedScope_RestoresPreviousTrace()
|
||||
{
|
||||
var context = new TraceContext();
|
||||
Guid outer = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
Guid inner = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
using (context.BeginTrace(outer))
|
||||
{
|
||||
using (context.BeginTrace(inner))
|
||||
{
|
||||
Assert.Equal(inner, context.CurrentTraceId);
|
||||
}
|
||||
|
||||
Assert.Equal(outer, context.CurrentTraceId);
|
||||
}
|
||||
|
||||
Assert.Null(context.CurrentTraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsyncFlow_PreservesTraceAcrossAwait()
|
||||
{
|
||||
var context = new TraceContext();
|
||||
Guid trace = Guid.NewGuid();
|
||||
|
||||
using (context.BeginTrace(trace))
|
||||
{
|
||||
await Task.Delay(20);
|
||||
Assert.Equal(trace, context.CurrentTraceId);
|
||||
}
|
||||
|
||||
Assert.Null(context.CurrentTraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsyncParallel_NoTraceIdLeak()
|
||||
{
|
||||
var context = new TraceContext();
|
||||
Guid first = Guid.NewGuid();
|
||||
Guid second = Guid.NewGuid();
|
||||
|
||||
async Task RunAsync(Guid traceId)
|
||||
{
|
||||
using (context.BeginTrace(traceId))
|
||||
{
|
||||
await Task.Delay(30);
|
||||
Assert.Equal(traceId, context.CurrentTraceId);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(RunAsync(first), RunAsync(second));
|
||||
|
||||
Assert.Null(context.CurrentTraceId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user