Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -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);
}
}