Files
2026-10-23 18:46:50 +02:00

408 lines
16 KiB
C#

using Omika.Core.Observability;
namespace Omika.Tests.Observability;
/// <summary>Tests de la capture centralisée d'exceptions (Phase 6).</summary>
[Collection("TelemetrieStatique")]
public class ExceptionTelemetryTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-exc-" + Guid.NewGuid().ToString("N"));
public ExceptionTelemetryTests()
{
Directory.CreateDirectory(_dir);
}
[Fact]
public void LogException_EmitsErrorEventWithExceptionDetail()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new InvalidOperationException("boom"),
TelemetryEventCodes.MobileHandledException,
component: "SyncEngine");
OmikaEvent evt = Evenement(telemetry, "SyncEngine");
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
Assert.Equal("System.InvalidOperationException", evt.ExceptionType);
Assert.Equal("boom", evt.Message);
Assert.False(evt.Success);
Assert.Equal("SyncEngine", evt.Component);
}
[Fact]
public void LogException_TruncatesLongFields()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new Exception(new string('m', 500)) { Data = { } },
TelemetryEventCodes.MobileHandledException);
OmikaEvent evt = Evenement(telemetry, errorCode: "Exception");
Assert.NotNull(evt.Message);
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
Assert.NotNull(evt.ExceptionType);
Assert.True(evt.ExceptionType!.Length <= ExceptionTelemetry.MaxExceptionTypeLength);
}
[Fact]
public void Capture_Fatal_WritesCrashFileAndEmitsCritical()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, _dir);
ExceptionTelemetry.Capture(new InvalidOperationException("crash"), isFatal: true);
OmikaEvent evt = Evenement(telemetry, "UnhandledException");
Assert.Equal(TelemetryLevel.Critical, evt.Level);
Assert.Equal(TelemetryEventCodes.MobileUnhandledException.Value, evt.EventCode.Value);
string[] crashFiles = Directory.EnumerateFiles(_dir, "omika-crash-*.log").ToArray();
Assert.Single(crashFiles);
Assert.Contains("crash", File.ReadAllText(crashFiles[0]));
}
[Fact]
public void Capture_NonFatal_EmitsError_WithoutCrashFile()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, _dir);
ExceptionTelemetry.Capture(new InvalidOperationException("bg"), isFatal: false);
Assert.Equal("bg", Evenement(telemetry, "TaskScheduler.UnobservedTask").Message);
Assert.Empty(Directory.EnumerateFiles(_dir, "omika-crash-*.log"));
}
[Fact]
public void Capture_WithoutInitializedTelemetry_DoesNotThrow()
{
ExceptionTelemetry.Initialize(null, _dir);
ExceptionTelemetry.Capture(new InvalidOperationException("x"), isFatal: true);
}
[Fact]
public void LogException_SignaleLaTroncatureParUnMarqueur()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new Exception(new string('m', 500)),
TelemetryEventCodes.MobileHandledException,
component: "TestTelemetrie");
OmikaEvent evt = Evenement(telemetry, "TestTelemetrie");
Assert.NotNull(evt.Message);
Assert.EndsWith("…[tronqué]", evt.Message, StringComparison.Ordinal);
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
}
// ===== Dépliage des agrégations =====
// Reproduit le payload réel d'une tâche non observée : le runtime relance
// l'exception du finaliseur sous forme d'AggregateException (message d'enveloppe,
// pile nulle). Sans dépliage, le diagnostic n'indique ni le type, ni le message,
// ni l'origine de la cause réelle.
[Fact]
public void Capture_TacheNonObservee_ExposeLaCauseInterneEtSaPile()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, _dir);
// Cause RÉELLE du runtime : toucher le jeton d'une source libérée lève
// exactement l'exception du payload de production, message compris.
var cts = new CancellationTokenSource();
cts.Dispose();
Exception cause = LeverReelle(() => _ = cts.Token);
Assert.Equal("The CancellationTokenSource has been disposed.", cause.Message);
var aggregate = new AggregateException(
"A Task's exception(s) were not observed either by Waiting on the Task or accessing its "
+ "Exception property. As a result, the unobserved exception was rethrown by the finalizer thread.",
cause);
ExceptionTelemetry.Capture(aggregate, isFatal: false);
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "ObjectDisposedException");
Assert.Equal(TelemetryEventCodes.MobileHandledException.Value, evt.EventCode.Value);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("TaskScheduler.UnobservedTask", evt.Component);
Assert.Equal("ObjectDisposedException", evt.ErrorCode);
Assert.Equal("System.ObjectDisposedException", evt.ExceptionType);
Assert.Equal("The CancellationTokenSource has been disposed.", evt.Message);
Assert.False(string.IsNullOrWhiteSpace(evt.StackTrace));
Assert.Contains(nameof(LeverReelle), evt.StackTrace!);
Assert.DoesNotContain("unobserved exception was rethrown", evt.Message!);
}
[Fact]
public void Capture_AgregatSansPile_PrefereLaCausePorteuseDUnePile()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
// La première interne n'a jamais été levée (aucune pile) : c'est la seconde,
// porteuse d'une pile, qui localise l'origine.
var aggregate = new AggregateException(
new InvalidOperationException("sans pile"),
Lever(new IOException("avec pile")));
ExceptionTelemetry.Capture(aggregate, isFatal: false);
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "IOException");
Assert.Equal("System.IO.IOException", evt.ExceptionType);
Assert.StartsWith("avec pile", evt.Message);
Assert.Contains("sans pile", evt.Message);
}
[Fact]
public void Capture_AgregatsImbriques_ResoutLaCauseRacine()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
var aggregate = new AggregateException(
new AggregateException(Lever(new ObjectDisposedException(null, "cause racine"))));
ExceptionTelemetry.Capture(aggregate, isFatal: false);
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "ObjectDisposedException");
Assert.Equal("System.ObjectDisposedException", evt.ExceptionType);
Assert.Equal("cause racine", evt.Message);
}
[Fact]
public void Capture_AgregatSansCauseInterne_ConserveLEnveloppe()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.Capture(new AggregateException("agrégat vide"), isFatal: false);
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "AggregateException");
Assert.Equal("AggregateException", evt.ErrorCode);
Assert.Equal("System.AggregateException", evt.ExceptionType);
Assert.Equal("agrégat vide", evt.Message);
}
[Fact]
public void Capture_AgregatMultiCauses_MessageBorneEtCiteLesAutresCauses()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
var aggregate = new AggregateException(
Enumerable.Range(0, 12).Select(i => Lever(new TimeoutException(new string('t', 200) + i))));
ExceptionTelemetry.Capture(aggregate, isFatal: false);
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "TimeoutException");
Assert.Equal("System.TimeoutException", evt.ExceptionType);
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
Assert.Contains("+11 autre(s) cause(s) agrégée(s)", evt.Message);
}
[Fact]
public void Capture_Fatal_Agregat_EcritLaCauseDansLeFichierDeCrash()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, _dir);
ExceptionTelemetry.Capture(
new AggregateException(Lever(new ObjectDisposedException("crash agrégé"))),
isFatal: true);
Assert.Equal(TelemetryLevel.Critical, Evenement(telemetry, "UnhandledException").Level);
string[] crashFiles = Directory.EnumerateFiles(_dir, "omika-crash-*.log").ToArray();
Assert.Single(crashFiles);
string contenu = File.ReadAllText(crashFiles[0]);
Assert.Contains("System.ObjectDisposedException", contenu);
Assert.Contains("crash agrégé", contenu);
Assert.DoesNotContain("AggregateException", contenu);
}
// ===== Chaîne des causes internes =====
// Reproduit le payload réel « Connection failure » : le message du handler HTTP
// Android ne dit pas s'il s'agit d'un échec DNS, d'une connexion refusée, d'une
// route absente ou d'un échec TLS. La cause interne, elle, le dit.
[Fact]
public void Capture_ExceptionAvecCauseInterne_CiteLaCauseInterne()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new HttpRequestException(
"Connection failure",
new IOException("failed to connect to /10.0.0.12:443")),
TelemetryEventCodes.MobileHandledException,
component: "TestTelemetrie");
OmikaEvent evt = Evenement(telemetry, "TestTelemetrie");
// Le type transmis reste celui de l'exception attrapée : la cause interne
// enrichit le message, elle ne remplace pas l'identité de l'erreur.
Assert.Equal("HttpRequestException", evt.ErrorCode);
Assert.Equal("System.Net.Http.HttpRequestException", evt.ExceptionType);
Assert.NotNull(evt.Message);
Assert.StartsWith("Connection failure", evt.Message);
Assert.Contains(
"cause interne : IOException: failed to connect to /10.0.0.12:443",
evt.Message);
}
[Fact]
public void Capture_CausesInternesImbriquees_CiteLaChaineDansLOrdre()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new InvalidOperationException(
"haut",
new IOException("milieu", new TimeoutException("bas"))),
TelemetryEventCodes.MobileHandledException,
component: "TestTelemetrie");
string message = Evenement(telemetry, "TestTelemetrie").Message!;
int haut = message.IndexOf("haut", StringComparison.Ordinal);
int milieu = message.IndexOf("milieu", StringComparison.Ordinal);
int bas = message.IndexOf("bas", StringComparison.Ordinal);
Assert.True(haut >= 0, message);
Assert.True(milieu > haut, message);
Assert.True(bas > milieu, message);
Assert.Contains("IOException: milieu", message);
Assert.Contains("TimeoutException: bas", message);
}
[Fact]
public void Capture_ChaineDeCausesTropProfonde_EstBornee()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
Exception profonde = new Exception("cause-10");
for (int i = 9; i >= 1; i--)
{
profonde = new InvalidOperationException($"cause-{i:00}", profonde);
}
ExceptionTelemetry.LogException(
profonde,
TelemetryEventCodes.MobileHandledException,
component: "TestTelemetrie");
string message = Evenement(telemetry, "TestTelemetrie").Message!;
Assert.Contains("cause-01", message);
Assert.DoesNotContain("cause-07", message);
Assert.Contains("…", message);
Assert.True(message.Length <= ExceptionTelemetry.MaxMessageLength, message);
}
[Fact]
public void Capture_Agregat_CiteLaCauseInterneEtLesAutresCauses()
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
ExceptionTelemetry.LogException(
new AggregateException(
Lever(new HttpRequestException("échec réseau", new IOException("connexion refusée"))),
Lever(new InvalidOperationException("autre cause"))),
TelemetryEventCodes.MobileHandledException,
component: "TestTelemetrie");
string message = Evenement(telemetry, "TestTelemetrie").Message!;
Assert.StartsWith("échec réseau", message);
Assert.Contains("cause interne : IOException: connexion refusée", message);
Assert.Contains("+1 autre(s) cause(s) agrégée(s)", message);
Assert.Contains("InvalidOperationException: autre cause", message);
}
/// <summary>
/// Événement de diagnostic produit par CE test.
///
/// La façade <see cref="ExceptionTelemetry"/> est un état statique global, partagé
/// avec les classes de tests exécutées en parallèle : une capture peut donc recevoir
/// les événements d'un autre test (usage ou erreur) et un <c>Assert.Single</c> sur
/// toute la collection devient intermittente. On isole donc l'événement attendu par
/// son composant et/ou son code d'erreur.
/// </summary>
private static DiagnosticEvent Evenement(
CapturingTelemetry telemetry,
string? component = null,
string? errorCode = null) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => (component is null || e.Component == component)
&& (errorCode is null || e.ErrorCode == errorCode));
/// <summary>
/// Retourne l'exception réellement levée puis attrapée : une exception seulement
/// construite n'a pas de pile d'appels, or c'est la pile qui localise l'origine.
/// </summary>
private static Exception Lever(Exception exception)
{
try
{
throw exception;
}
catch (Exception capturee)
{
return capturee;
}
}
/// <summary>
/// Exécute une action qui doit lever : retourne l'exception réelle du runtime
/// (avec son message et sa pile d'origine), pour tester sur la cause authentique.
/// </summary>
private static Exception LeverReelle(Action action)
{
try
{
action();
}
catch (Exception capturee)
{
return capturee;
}
throw new InvalidOperationException("Aucune exception levée : le scénario de test est invalide.");
}
public void Dispose()
{
ExceptionTelemetry.Initialize(null, null);
try
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}