Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class ApiTelemetryContextEnricherTests
|
||||
{
|
||||
private sealed class RecordingTelemetry : IOmikaTelemetry
|
||||
{
|
||||
public OmikaEvent? Last { get; private set; }
|
||||
|
||||
public void Track(TelemetryEvent telemetryEvent) => Last = telemetryEvent;
|
||||
|
||||
public void Log(DiagnosticEvent diagnosticEvent) => Last = diagnosticEvent;
|
||||
}
|
||||
|
||||
private static DeviceInfoSnapshot Snapshot() => new()
|
||||
{
|
||||
AppVersion = "1.0.0.0",
|
||||
Platform = "Windows",
|
||||
OperatingSystem = "Windows 11",
|
||||
OperatingSystemVersion = "10.0"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Log_WithinRequestContext_EnrichesTraceSessionIdentityAndDevice()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
|
||||
using (traceContext.BeginTrace(traceId))
|
||||
using (requestContext.Begin(traceId, sessionId))
|
||||
{
|
||||
requestContext.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
enricher.Log(new DiagnosticEvent { EventCode = TelemetryEventCodes.ApiHttpRequestStarted });
|
||||
}
|
||||
|
||||
var @event = recorder.Last!;
|
||||
Assert.Equal(traceId, @event.TraceId);
|
||||
Assert.Equal(sessionId, @event.SessionId);
|
||||
Assert.Equal("device-1", @event.DeviceId);
|
||||
Assert.Equal("CLIENT01", @event.ClientId);
|
||||
Assert.Equal("42", @event.UserId);
|
||||
Assert.Equal("7", @event.CollaborateurId);
|
||||
Assert.Equal("1.0.0.0", @event.AppVersion);
|
||||
Assert.Equal("Windows", @event.Platform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_OutsideRequestContext_StillEnrichesDeviceInfo()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.ApiHttpRequestStarted });
|
||||
|
||||
var @event = recorder.Last!;
|
||||
Assert.Null(@event.TraceId);
|
||||
Assert.Null(@event.SessionId);
|
||||
Assert.Equal("1.0.0.0", @event.AppVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_ExplicitTraceId_IsNotOverwritten()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
Guid explicitTrace = Guid.NewGuid();
|
||||
enricher.Log(new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.ApiHttpRequestFailed,
|
||||
TraceId = explicitTrace
|
||||
});
|
||||
|
||||
Assert.Equal(explicitTrace, recorder.Last!.TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class CorrelationHeadersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Headers_HaveExpectedNames()
|
||||
{
|
||||
Assert.Equal("X-Omika-TraceId", CorrelationHeaders.TraceId);
|
||||
Assert.Equal("X-Omika-SessionId", CorrelationHeaders.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseTraceId_ValidGuid_ReturnsTrue()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
Assert.True(CorrelationHeaders.TryParseTraceId(guid.ToString("D"), out Guid parsed));
|
||||
Assert.Equal(guid, parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("2ddc12ae")]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("2ddc12ae-4f1b-4c3a-zzzz-123456789012")]
|
||||
[InlineData(null)]
|
||||
public void TryParseTraceId_Invalid_ReturnsFalse(string? value)
|
||||
{
|
||||
Assert.False(CorrelationHeaders.TryParseTraceId(value, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseSessionId_ValidGuid_ReturnsTrue()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
Assert.True(CorrelationHeaders.TryParseSessionId(guid.ToString("D"), out Guid parsed));
|
||||
Assert.Equal(guid, parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData(null)]
|
||||
public void TryParseSessionId_Invalid_ReturnsFalse(string? value)
|
||||
{
|
||||
Assert.False(CorrelationHeaders.TryParseSessionId(value, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Omika.Core.Abstractions;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Core.Contracts.Mobile.License;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Runtime;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests HTTP de la corrélation Phase 3 (Mobile → API) : propagation du TraceId,
|
||||
/// génération si absent, rejet d'un header invalide, isolation concurrente,
|
||||
/// réponse d'erreur contrôlée avec TraceId, fail-open (observabilité en panne,
|
||||
/// NullTelemetry) et absence de données interdites dans les fichiers diagnostics.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class CorrelationMiddlewareIntegrationTests : IDisposable
|
||||
{
|
||||
private const string SigningKey = "test-signing-key-for-integration-tests-only";
|
||||
|
||||
private readonly string _dir = CreateTempDirectory();
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public CorrelationMiddlewareIntegrationTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
_factory = CreateFactory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
ApplicationRuntimeContext.Reset();
|
||||
DeleteDirectory(_dir);
|
||||
}
|
||||
|
||||
// ===== Propagation (spec §35) =====
|
||||
|
||||
[Fact]
|
||||
public async Task Propagation_SentTraceId_IsReusedAndEchoedBack()
|
||||
{
|
||||
Guid traceId = Guid.NewGuid();
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, traceId.ToString("D"));
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(
|
||||
traceId.ToString("D"),
|
||||
Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)));
|
||||
}
|
||||
|
||||
// ===== Header absent (spec §36) =====
|
||||
|
||||
[Fact]
|
||||
public async Task AbsentTraceId_GeneratesNewGuid()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId));
|
||||
Assert.True(Guid.TryParse(value, out _));
|
||||
}
|
||||
|
||||
// ===== Header invalide (spec §37) =====
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidTraceId_IsIgnored_GeneratesNewGuid_BusinessExecutes()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, "INVALID");
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId));
|
||||
Assert.True(Guid.TryParse(value, out _));
|
||||
Assert.NotEqual("INVALID", value);
|
||||
}
|
||||
|
||||
// ===== SessionId (spec §38) =====
|
||||
|
||||
[Fact]
|
||||
public async Task SessionId_Valid_IsEchoedBack()
|
||||
{
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.Add(CorrelationHeaders.SessionId, sessionId.ToString("D"));
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(
|
||||
sessionId.ToString("D"),
|
||||
Assert.Single(response.Headers.GetValues(CorrelationHeaders.SessionId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionId_Invalid_IsIgnored_RequestContinues()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.Add(CorrelationHeaders.SessionId, "NOT-A-GUID");
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains(CorrelationHeaders.SessionId));
|
||||
}
|
||||
|
||||
// ===== Concurrence (spec §39) =====
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_Requests_NoTraceLeak()
|
||||
{
|
||||
Guid traceA = Guid.NewGuid();
|
||||
Guid traceB = Guid.NewGuid();
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
var requestA = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
requestA.Headers.Add(CorrelationHeaders.TraceId, traceA.ToString("D"));
|
||||
var requestB = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
requestB.Headers.Add(CorrelationHeaders.TraceId, traceB.ToString("D"));
|
||||
|
||||
Task<HttpResponseMessage> taskA = client.SendAsync(requestA);
|
||||
Task<HttpResponseMessage> taskB = client.SendAsync(requestB);
|
||||
await Task.WhenAll(taskA, taskB);
|
||||
|
||||
HttpResponseMessage responseA = await taskA;
|
||||
HttpResponseMessage responseB = await taskB;
|
||||
|
||||
Assert.Equal(
|
||||
traceA.ToString("D"),
|
||||
Assert.Single(responseA.Headers.GetValues(CorrelationHeaders.TraceId)));
|
||||
Assert.Equal(
|
||||
traceB.ToString("D"),
|
||||
Assert.Single(responseB.Headers.GetValues(CorrelationHeaders.TraceId)));
|
||||
}
|
||||
|
||||
// ===== Réponse d'erreur (spec §40) =====
|
||||
|
||||
[Fact]
|
||||
public async Task UnhandledException_ReturnsControlledError_WithTraceId_NoLeak()
|
||||
{
|
||||
var licenseService = new FakeLicenseService
|
||||
{
|
||||
ActivateHandler = () => throw new InvalidOperationException(
|
||||
"Secret ConnectionString=Server=prod;Password=hidden")
|
||||
};
|
||||
using var factory = CreateFactory(_dir, services =>
|
||||
{
|
||||
services.RemoveAll<ILicenseService>();
|
||||
services.AddScoped<ILicenseService>(_ => licenseService);
|
||||
});
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
Guid traceId = Guid.NewGuid();
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/license/activate");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, traceId.ToString("D"));
|
||||
request.Content = JsonContent.Create(new LicenseActivateRequest
|
||||
{
|
||||
CodeClient = "BATIK.01",
|
||||
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3",
|
||||
DeviceId = "device-abc-123"
|
||||
});
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Equal(
|
||||
traceId.ToString("D"),
|
||||
Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
Assert.True(document.RootElement.TryGetProperty("traceId", out JsonElement traceIdElement));
|
||||
Assert.Equal(traceId.ToString("D"), traceIdElement.GetString());
|
||||
Assert.True(document.RootElement.TryGetProperty("message", out JsonElement messageElement));
|
||||
Assert.Equal("Une erreur interne est survenue.", messageElement.GetString());
|
||||
|
||||
// Aucune fuite : ni message interne, ni secret, ni stack trace.
|
||||
Assert.DoesNotContain("ConnectionString", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Password", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Secret", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(" at ", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ===== Observabilité en panne (spec §41) =====
|
||||
|
||||
[Fact]
|
||||
public async Task ExporterBroken_InaccessibleDirectory_HttpResultIdentical()
|
||||
{
|
||||
string blocker = Path.Combine(_dir, "blocker");
|
||||
File.WriteAllText(blocker, "x");
|
||||
string inaccessibleDir = Path.Combine(blocker, "sub");
|
||||
|
||||
using var factory = CreateFactory(inaccessibleDir);
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId));
|
||||
Assert.True(Guid.TryParse(value, out _));
|
||||
}
|
||||
|
||||
// ===== Sécurité des fichiers (spec §42) =====
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticFiles_ContainNoForbiddenData()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "some-secret-bearer-token");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, Guid.NewGuid().ToString("D"));
|
||||
await client.SendAsync(request);
|
||||
await client.GetAsync("/health");
|
||||
|
||||
// Attend de façon déterministe que l'exporteur ait réellement écrit et flushé
|
||||
// au moins un événement JSONL (on synchronise sur le contenu, pas sur un délai).
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_dir, "*.jsonl")
|
||||
.Any(file => ReadAllTextShared(file).Contains("\"eventCode\"", StringComparison.Ordinal)));
|
||||
|
||||
// Lit le contenu pendant que l'exporteur peut encore écrire. Le writer ouvre
|
||||
// avec FileAccess.Write + FileShare.Read, donc la lecture doit ouvrir avec
|
||||
// FileShare.ReadWrite (jamais File.ReadAllText, dont le FileShare.Read rejette
|
||||
// l'écriture concurrente et provoque « used by another process »).
|
||||
string content = string.Join(
|
||||
"\n",
|
||||
Directory.EnumerateFiles(_dir, "*.jsonl").Select(ReadAllTextShared));
|
||||
|
||||
foreach (string forbidden in new[]
|
||||
{
|
||||
"Authorization", "Bearer", "Password", "Token", "Cookie",
|
||||
"ConnectionString", "RequestBody", "ResponseBody",
|
||||
"some-secret-bearer-token"
|
||||
})
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IOmikaTelemetry qui lève (spec §34) =====
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowingTelemetry_MiddlewareStillProducesResponse()
|
||||
{
|
||||
using var factory = CreateFactory(_dir, services =>
|
||||
{
|
||||
services.RemoveAll<IOmikaTelemetry>();
|
||||
services.AddSingleton<IOmikaTelemetry>(new ThrowingTelemetry());
|
||||
});
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId));
|
||||
Assert.True(Guid.TryParse(value, out _));
|
||||
}
|
||||
|
||||
// ===== NullTelemetry (spec §43) =====
|
||||
|
||||
[Fact]
|
||||
public async Task NullTelemetry_RoutesStillWork()
|
||||
{
|
||||
using var factory = CreateFactory(_dir, services =>
|
||||
{
|
||||
services.RemoveAll<IOmikaTelemetry>();
|
||||
services.RemoveAll<IEventTracker>();
|
||||
services.RemoveAll<IDiagnosticLogger>();
|
||||
services.AddSingleton<IOmikaTelemetry>(NullTelemetryService.Instance);
|
||||
services.AddSingleton<IEventTracker>(NullTelemetryService.Instance);
|
||||
services.AddSingleton<IDiagnosticLogger>(NullTelemetryService.Instance);
|
||||
});
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId));
|
||||
Assert.True(Guid.TryParse(value, out _));
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory(
|
||||
string directory,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Hfsql:Server"] = "test-server",
|
||||
["Hfsql:DatabaseName"] = "test-db",
|
||||
["MobileAuth:SigningKey"] = SigningKey
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
services.RemoveAll<IDatabaseRuntimeInitializer>();
|
||||
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
|
||||
|
||||
services.RemoveAll<IHfsqlConnection>();
|
||||
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
|
||||
|
||||
services.RemoveAll<ILicenseService>();
|
||||
services.AddScoped<ILicenseService>(_ => new FakeLicenseService());
|
||||
|
||||
services.RemoveAll<IMobileBootstrapService>();
|
||||
services.AddScoped<IMobileBootstrapService>(_ => new FakeBootstrapService());
|
||||
|
||||
// Dossier de diagnostic propre au test (remplace le défaut serveur).
|
||||
services.Configure<LocalDiagnosticOptions>(options => options.Directory = directory);
|
||||
|
||||
configureServices?.Invoke(services);
|
||||
});
|
||||
});
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-correlation-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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lit un fichier JSONL en cohabitation avec l'exporteur encore actif. Le writer
|
||||
/// ouvre avec <c>FileAccess.Write, FileShare.Read</c> (un seul writer, lectures
|
||||
/// autorisées) ; un lecteur doit donc ouvrir avec <c>FileShare.ReadWrite</c>.
|
||||
/// </summary>
|
||||
private static string ReadAllTextShared(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private 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.");
|
||||
}
|
||||
|
||||
// ===== Fakes =====
|
||||
|
||||
private sealed class ThrowingTelemetry : IOmikaTelemetry
|
||||
{
|
||||
public void Track(TelemetryEvent telemetryEvent) => throw new InvalidOperationException("telemetry down");
|
||||
|
||||
public void Log(DiagnosticEvent diagnosticEvent) => throw new InvalidOperationException("telemetry down");
|
||||
}
|
||||
|
||||
private sealed class FakeLicenseService : ILicenseService
|
||||
{
|
||||
public Func<Task<LicenseResponse>>? ActivateHandler { get; set; }
|
||||
|
||||
public Task<LicenseResponse> ActivateAsync(
|
||||
LicenseActivateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ActivateHandler is null
|
||||
? Task.FromResult(new LicenseResponse())
|
||||
: ActivateHandler();
|
||||
|
||||
public Task<LicenseResponse> ValidateAsync(
|
||||
LicenseValidateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new LicenseResponse());
|
||||
|
||||
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
|
||||
string collaborateurId,
|
||||
string codeClient,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
|
||||
string collaborateurId,
|
||||
string codeClient,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<bool> ReleaseMobileLicenceAsync(
|
||||
string collaborateurId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class FakeBootstrapService : IMobileBootstrapService
|
||||
{
|
||||
public Task<MobileBootstrapResponse> GetBootstrapAsync(
|
||||
MobileTokenIdentity identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new MobileBootstrapResponse());
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
|
||||
{
|
||||
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
return Task.FromResult(Result.Success());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
|
||||
public void Dispose() { }
|
||||
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task CloseAsync() => Task.CompletedTask;
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class RequestDiagnosticContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Begin_SetsContext_AndRestoresOnDispose()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
Assert.Null(context.SessionId);
|
||||
Assert.Null(context.StartedAtUtc);
|
||||
|
||||
using (context.Begin(traceId, sessionId))
|
||||
{
|
||||
Assert.Equal(traceId, context.TraceId);
|
||||
Assert.Equal(sessionId, context.SessionId);
|
||||
Assert.NotNull(context.StartedAtUtc);
|
||||
}
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
Assert.Null(context.SessionId);
|
||||
Assert.Null(context.StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetIdentity_WithinScope_SetsFields()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
using (context.Begin(Guid.NewGuid()))
|
||||
{
|
||||
context.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
|
||||
Assert.Equal("device-1", context.DeviceId);
|
||||
Assert.Equal("CLIENT01", context.ClientId);
|
||||
Assert.Equal("42", context.UserId);
|
||||
Assert.Equal("7", context.CollaborateurId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetIdentity_OutsideScope_IsNoOp()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
context.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
|
||||
Assert.Null(context.DeviceId);
|
||||
Assert.Null(context.ClientId);
|
||||
Assert.Null(context.UserId);
|
||||
Assert.Null(context.CollaborateurId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NestedScopes_AreIsolated_AndRestored()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
Guid a = Guid.NewGuid();
|
||||
Guid b = Guid.NewGuid();
|
||||
|
||||
using (context.Begin(a))
|
||||
{
|
||||
await Task.Yield();
|
||||
Assert.Equal(a, context.TraceId);
|
||||
|
||||
using (context.Begin(b))
|
||||
{
|
||||
Assert.Equal(b, context.TraceId);
|
||||
}
|
||||
|
||||
Assert.Equal(a, context.TraceId);
|
||||
}
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class TraceIdPropagationHandlerTests
|
||||
{
|
||||
private sealed class FakeInnerHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_NoCurrentTrace_GeneratesTraceIdHeader()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
await client.GetAsync("http://test.local/");
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
Assert.True(inner.LastRequest.Headers.TryGetValues(CorrelationHeaders.TraceId, out var values));
|
||||
Assert.True(Guid.TryParse(Assert.Single(values), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_CurrentTrace_UsesIt()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
Guid expected = Guid.NewGuid();
|
||||
var inner = new FakeInnerHandler();
|
||||
|
||||
using (traceContext.BeginTrace(expected))
|
||||
{
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
await client.GetAsync("http://test.local/");
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
expected.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.TraceId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithSession_AddsSessionHeader()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var session = new DiagnosticSession();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, session, inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
await client.GetAsync("http://test.local/");
|
||||
|
||||
Assert.Equal(
|
||||
session.SessionId.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.SessionId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotModifyAuthorization()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "http://test.local/");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-abc");
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
Assert.Equal("Bearer", inner.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("token-abc", inner.LastRequest.Headers.Authorization!.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ExistingTraceHeader_NotOverwritten()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
Guid existing = Guid.NewGuid();
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "http://test.local/");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, existing.ToString("D"));
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(
|
||||
existing.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.TraceId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotModifyPayload()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "http://test.local/");
|
||||
request.Content = new StringContent("{\"a\":1}", System.Text.Encoding.UTF8, "application/json");
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
string body = await inner.LastRequest!.Content!.ReadAsStringAsync();
|
||||
Assert.Equal("{\"a\":1}", body);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Instrumentation de chaque bouton (T18 étendu) : le FeatureCode est dérivé de façon
|
||||
/// STABLE depuis le nom de la commande ([RelayCommand] → [CallerMemberName]) — suffixe
|
||||
/// <c>Async</c> retiré, conversion en UPPER_SNAKE_CASE, codes canoniques (déjà avec '_')
|
||||
/// préservés, entrées vides → ACTION. Ces transformations sont pures et déterministes.
|
||||
/// </summary>
|
||||
public class DiagnosticFeatureCodesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("SaveAsync", "SAVE")]
|
||||
[InlineData("Save", "SAVE")]
|
||||
[InlineData("Delete", "DELETE")]
|
||||
[InlineData("DeleteDocumentAsync", "DELETE_DOCUMENT")]
|
||||
[InlineData("NavigateToIntervention", "NAVIGATE_TO_INTERVENTION")]
|
||||
[InlineData("LoadPdf", "LOAD_PDF")]
|
||||
[InlineData("EmailSend", "EMAIL_SEND")]
|
||||
[InlineData("EMAIL_SEND", "EMAIL_SEND")]
|
||||
[InlineData("ReportGenerate", "REPORT_GENERATE")]
|
||||
[InlineData("INTERVENTION_SEARCH_RUN", "INTERVENTION_SEARCH_RUN")]
|
||||
[InlineData("RotateRight", "ROTATE_RIGHT")]
|
||||
[InlineData("ZoomIn", "ZOOM_IN")]
|
||||
[InlineData("GoBackAsync", "GO_BACK")]
|
||||
[InlineData("", "ACTION")]
|
||||
[InlineData(" ", "ACTION")]
|
||||
[InlineData(null, "ACTION")]
|
||||
public void FromCommandName_NormalizesToStableFeatureCode(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DiagnosticFeatureCodes.FromCommandName(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Omika.App.Navigation;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// T18 (§71/§72) : le mapping AppPage → ScreenCode est total, stable, au format
|
||||
/// UPPER_SNAKE_CASE ASCII, sans identifiant métier, sans nom technique de Page/ViewModel.
|
||||
/// </summary>
|
||||
public partial class DiagnosticScreenMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_EveryAppPage_HasScreenCode()
|
||||
{
|
||||
foreach (AppPage page in Enum.GetValues<AppPage>())
|
||||
{
|
||||
Assert.True(
|
||||
DiagnosticScreenMapping.Resolve(page) is not null,
|
||||
$"AppPage.{page} n'a pas de ScreenCode : le mapping doit être TOTAL sur l'énumération.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ProducesCatalogValues()
|
||||
{
|
||||
var catalog = new HashSet<string>(DiagnosticScreenCodes.All, StringComparer.Ordinal);
|
||||
|
||||
foreach (AppPage page in Enum.GetValues<AppPage>())
|
||||
{
|
||||
string code = DiagnosticScreenMapping.Resolve(page)!;
|
||||
Assert.Contains(code, catalog);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Agenda_ReturnsAgenda()
|
||||
{
|
||||
Assert.Equal(DiagnosticScreenCodes.Agenda, DiagnosticScreenMapping.Resolve(AppPage.Home));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SiteDetail_ReturnsSiteDetail()
|
||||
{
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, DiagnosticScreenMapping.Resolve(AppPage.SiteDetail));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_InterventionSearch_ReturnsInterventionSearch()
|
||||
{
|
||||
Assert.Equal(DiagnosticScreenCodes.InterventionSearch, DiagnosticScreenMapping.Resolve(AppPage.Interventions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScreenCodes_AreUppercaseSnakeCaseAscii_WithoutIds()
|
||||
{
|
||||
var regex = UppercaseSnake();
|
||||
|
||||
foreach (string code in DiagnosticScreenCodes.All)
|
||||
{
|
||||
Assert.Matches(regex, code);
|
||||
// §72 : aucun ID métier dynamique (site/rendez-vous/intervention/user/GUID).
|
||||
Assert.DoesNotContain('-', code); // pas de GUID
|
||||
Assert.DoesNotContain(code, char.IsDigit); // pas de numéro d'ID
|
||||
Assert.DoesNotContain(code, char.IsLower); // pas de casse libre
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-Z][A-Z0-9_]*$")]
|
||||
private static partial Regex UppercaseSnake();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class EventCodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_Valid_ProducesCanonicalValue()
|
||||
{
|
||||
EventCode code = EventCode.Create("mob", "sync", 1);
|
||||
|
||||
Assert.True(code.IsValid);
|
||||
Assert.Equal("OMK-MOB-SYNC-001", code.Value);
|
||||
Assert.Equal("MOB", code.Zone);
|
||||
Assert.Equal("SYNC", code.Domain);
|
||||
Assert.Equal(1, code.Number);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("M", "SYNC", 1)]
|
||||
[InlineData("MOBILE", "SYNC", 1)]
|
||||
[InlineData("MOB", "S", 1)]
|
||||
public void Create_InvalidZoneOrDomain_Throws(string zone, string domain, int number)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => EventCode.Create(zone, domain, number));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MOB", "SYNC", 0)]
|
||||
[InlineData("MOB", "SYNC", 1000)]
|
||||
[InlineData("MOB", "SYNC", -1)]
|
||||
public void Create_InvalidNumber_Throws(string zone, string domain, int number)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(() => EventCode.Create(zone, domain, number));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidValue_ReturnsValid()
|
||||
{
|
||||
EventCode code = EventCode.Parse("OMK-MOB-SYNC-001");
|
||||
|
||||
Assert.True(code.IsValid);
|
||||
Assert.Equal("OMK-MOB-SYNC-001", code.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Lowercase_Canonicalizes()
|
||||
{
|
||||
EventCode code = EventCode.Parse("omk-mob-sync-001");
|
||||
|
||||
Assert.True(code.IsValid);
|
||||
Assert.Equal("OMK-MOB-SYNC-001", code.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("BAD")]
|
||||
[InlineData("OMK-MOB-SYNC")]
|
||||
[InlineData("OMK-MOB-SYNC-0000")]
|
||||
[InlineData("OMK-MOB-SYNC-ABC")]
|
||||
[InlineData("XX-MOB-SYNC-001")]
|
||||
public void Parse_Invalid_ReturnsInvalid(string? value)
|
||||
{
|
||||
Assert.False(EventCode.Parse(value).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsInvalid()
|
||||
{
|
||||
Assert.False(default(EventCode).IsValid);
|
||||
Assert.Null(default(EventCode).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_IsCaseSensitiveCanonical()
|
||||
{
|
||||
Assert.Equal(EventCode.Create("MOB", "SYNC", 1), EventCode.Parse("omk-mob-sync-001"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// Garde-fous d'architecture de la Phase 5 (§8/§9/§65/§66/§72) : les packages
|
||||
/// OpenTelemetry sont localisés UNIQUEMENT dans le projet infrastructure dédié
|
||||
/// (Omika.Observability.External), jamais dans Core ni dans le métier (App /
|
||||
/// App.Local / Data). Vérifications par réflexion sur les assemblies référencées.
|
||||
/// </summary>
|
||||
public class ExternalArchitectureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Core_DoesNotReferenceOpenTelemetry()
|
||||
{
|
||||
Assembly core = typeof(OmikaEvent).Assembly;
|
||||
|
||||
Assert.DoesNotContain(core.GetReferencedAssemblies(), a => a.Name!.Contains("OpenTelemetry"));
|
||||
Assert.DoesNotContain(
|
||||
core.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("SigNoz", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ClickHouse", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Omika.App")]
|
||||
[InlineData("Omika.App.Local")]
|
||||
[InlineData("Omika.Data")]
|
||||
public void BusinessAssemblies_DoNotReferenceExternalObservabilityInfrastructure(string assemblyName)
|
||||
{
|
||||
Assembly asm = Assembly.Load(assemblyName);
|
||||
|
||||
Assert.DoesNotContain(asm.GetReferencedAssemblies(), a =>
|
||||
a.Name!.Contains("OpenTelemetry")
|
||||
|| a.Name!.Contains("SigNoz")
|
||||
|| a.Name!.Contains("ClickHouse"));
|
||||
|
||||
Assert.DoesNotContain(
|
||||
asm.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("SigNoz", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ClickHouse", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ActivitySource", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("TracerProvider", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackAndLog_Path_ExposesOnlyVoidMethods_NoNetwork()
|
||||
{
|
||||
// Le chemin Track()/Log() du métier reste strictement void (never-throw,
|
||||
// non bloquant, sans réseau) : le réseau OTel est DERRIÈRE le TelemetryWorker.
|
||||
foreach (MethodInfo method in typeof(IOmikaTelemetry).GetMethods()
|
||||
.Concat(typeof(IEventTracker).GetMethods())
|
||||
.Concat(typeof(IDiagnosticLogger).GetMethods()))
|
||||
{
|
||||
Assert.Equal(typeof(void), method.ReturnType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Garde-fous de cardinalité des métriques externes (§36/§37).</summary>
|
||||
public class ExternalCardinalityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("component")]
|
||||
[InlineData("status_class")]
|
||||
[InlineData("method")]
|
||||
[InlineData("route_template")]
|
||||
[InlineData("event_priority")]
|
||||
[InlineData("level")]
|
||||
[InlineData("result")]
|
||||
public void MetricLabels_AllowLowCardinality(string key)
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.IsAllowedMetricLabel(key));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("traceid")]
|
||||
[InlineData("trace_id")]
|
||||
[InlineData("sessionid")]
|
||||
[InlineData("eventid")]
|
||||
[InlineData("operationid")]
|
||||
[InlineData("userid")]
|
||||
[InlineData("clientid")]
|
||||
[InlineData("collaborateurid")]
|
||||
[InlineData("email")]
|
||||
[InlineData("url")]
|
||||
public void MetricLabels_RejectHighCardinality(string key)
|
||||
{
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedMetricLabel(key));
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetricLabel_DetectsSubstringCaseInsensitive()
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel("SessionId"));
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel("some_OperationId_suffix"));
|
||||
Assert.False(ExternalAttributePolicy.IsForbiddenMetricLabel("component"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogAttributes_RejectForbiddenKeys()
|
||||
{
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("password"));
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("Authorization"));
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("connectionString"));
|
||||
Assert.True(ExternalAttributePolicy.IsAllowedLogAttribute("route_template"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenValues_AreDetected()
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.ContainsForbiddenValue("Bearer token abc"));
|
||||
Assert.True(ExternalAttributePolicy.ContainsForbiddenValue("password=123"));
|
||||
Assert.False(ExternalAttributePolicy.ContainsForbiddenValue("une valeur ordinaire"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Disjoncteur du pont externe (§16) : déterministe (horloge injectée).</summary>
|
||||
public class ExternalCircuitBreakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartsHealthy()
|
||||
{
|
||||
var circuit = NewCircuit();
|
||||
|
||||
Assert.Equal(ExternalObservabilityState.Healthy, circuit.State);
|
||||
Assert.True(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpensAfterThresholdFailures()
|
||||
{
|
||||
var circuit = NewCircuit(); // seuil = 3
|
||||
|
||||
circuit.RecordFailure();
|
||||
circuit.RecordFailure();
|
||||
Assert.Equal(ExternalObservabilityState.Degraded, circuit.State);
|
||||
Assert.True(circuit.AllowExport()); // dégradé mais autorisé
|
||||
|
||||
circuit.RecordFailure();
|
||||
Assert.Equal(ExternalObservabilityState.Open, circuit.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsWhileOpen()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(2, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
circuit.RecordFailure();
|
||||
|
||||
Assert.False(circuit.AllowExport()); // open : drop
|
||||
Assert.False(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbesAfterCooldown()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
Assert.False(circuit.AllowExport());
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport()); // half-open : autorise UNE sonde
|
||||
Assert.Equal(ExternalObservabilityState.Degraded, circuit.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessClosesCircuit()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport());
|
||||
|
||||
circuit.RecordSuccess();
|
||||
Assert.Equal(ExternalObservabilityState.Healthy, circuit.State);
|
||||
Assert.True(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeFailureReopensCircuit()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport()); // half-open sonde
|
||||
|
||||
circuit.RecordFailure(); // sonde échoue → ré-ouverture
|
||||
Assert.Equal(ExternalObservabilityState.Open, circuit.State);
|
||||
Assert.False(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverAllowsNegativeOrZeroCooldown()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.Zero, clock.Now);
|
||||
|
||||
circuit.RecordFailure();
|
||||
Assert.False(circuit.AllowExport()); // cooldown ramené à >= 1 s : encore ouvert
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
Assert.True(circuit.AllowExport()); // sonde possible après cooldown positif
|
||||
}
|
||||
|
||||
private static ExternalCircuitBreaker NewCircuit() =>
|
||||
new(3, TimeSpan.FromSeconds(30));
|
||||
|
||||
private sealed class MutableClock
|
||||
{
|
||||
private DateTimeOffset _now = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset Now() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// Compteurs du pont externe (Phase 5) : valeurs initiales à 0, incréments exacts,
|
||||
/// thread-safe (Interlocked), jamais-throw. Compteurs purement mémoire — jamais
|
||||
/// réinjectés dans IOmikaTelemetry (anti-boucle).
|
||||
/// </summary>
|
||||
public class ExternalObservabilityCountersTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllCounters_StartAtZero()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
Assert.Equal(0, counters.Accepted);
|
||||
Assert.Equal(0, counters.Exported);
|
||||
Assert.Equal(0, counters.DroppedDisabled);
|
||||
Assert.Equal(0, counters.DroppedCircuitOpen);
|
||||
Assert.Equal(0, counters.DroppedSecurity);
|
||||
Assert.Equal(0, counters.DroppedInvalid);
|
||||
Assert.Equal(0, counters.DroppedInternal);
|
||||
Assert.Equal(0, counters.Failure);
|
||||
Assert.Equal(0, counters.CircuitOpened);
|
||||
Assert.Equal(0, counters.Forwarded);
|
||||
Assert.Equal(0, counters.ForwardFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Increments_AreExact()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
counters.IncrementAccepted();
|
||||
counters.IncrementExported(7);
|
||||
counters.IncrementDroppedDisabled();
|
||||
counters.IncrementDroppedCircuitOpen();
|
||||
counters.IncrementDroppedSecurity();
|
||||
counters.IncrementDroppedInvalid();
|
||||
counters.IncrementDroppedInternal();
|
||||
counters.IncrementFailure();
|
||||
counters.IncrementCircuitOpened();
|
||||
counters.IncrementForwarded(3);
|
||||
counters.IncrementForwardFailure();
|
||||
|
||||
Assert.Equal(1, counters.Accepted);
|
||||
Assert.Equal(7, counters.Exported);
|
||||
Assert.Equal(1, counters.DroppedDisabled);
|
||||
Assert.Equal(1, counters.DroppedCircuitOpen);
|
||||
Assert.Equal(1, counters.DroppedSecurity);
|
||||
Assert.Equal(1, counters.DroppedInvalid);
|
||||
Assert.Equal(1, counters.DroppedInternal);
|
||||
Assert.Equal(1, counters.Failure);
|
||||
Assert.Equal(1, counters.CircuitOpened);
|
||||
Assert.Equal(3, counters.Forwarded);
|
||||
Assert.Equal(1, counters.ForwardFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementExported_AccumulatesByCount()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
counters.IncrementExported(5);
|
||||
counters.IncrementExported(11);
|
||||
|
||||
Assert.Equal(16, counters.Exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentIncrements_ProduceExactTotal()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int perThread = 1000;
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
Parallel.For(0, threads, _ =>
|
||||
{
|
||||
for (int i = 0; i < perThread; i++)
|
||||
{
|
||||
counters.IncrementAccepted();
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(threads * perThread, counters.Accepted);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Validation de la configuration du pont externe (§74/§75).</summary>
|
||||
public class ExternalObservabilityOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_IsDisabled_AndNotUsable()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions();
|
||||
|
||||
Assert.False(options.Enabled);
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_IsAlwaysValid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = false,
|
||||
OtlpEndpoint = "",
|
||||
ExportTimeoutSeconds = 0,
|
||||
MaxQueueSize = 0
|
||||
};
|
||||
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithEmptyEndpoint_IsInvalid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions { Enabled = true, OtlpEndpoint = "" };
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.NotEmpty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithValidHttpEndpoint_IsUsable()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://10.0.0.10:4318"
|
||||
};
|
||||
|
||||
Assert.True(options.IsUsable);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithNonHttpEndpoint_IsInvalid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "ftp://collector:4318"
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.Contains(options.Validate(), e => e.Contains("http", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(61)]
|
||||
public void ExportTimeoutSeconds_MustBeBetween1And60(int value)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
ExportTimeoutSeconds = value
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void NonPositiveBounds_AreInvalid(int value)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
MaxQueueSize = value
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoInstances_AreIndependent_NoSharedStaticState()
|
||||
{
|
||||
var a = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://a:4318",
|
||||
MaxQueueSize = 111
|
||||
};
|
||||
var b = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = false,
|
||||
OtlpEndpoint = "http://b:4318",
|
||||
MaxQueueSize = 222
|
||||
};
|
||||
|
||||
// Chaque instance conserve ses propres valeurs (aucune contamination croisée,
|
||||
// aucun état statique mutable partagé).
|
||||
Assert.True(a.Enabled);
|
||||
Assert.Equal("http://a:4318", a.OtlpEndpoint);
|
||||
Assert.Equal(111, a.MaxQueueSize);
|
||||
|
||||
Assert.False(b.Enabled);
|
||||
Assert.Equal("http://b:4318", b.OtlpEndpoint);
|
||||
Assert.Equal(222, b.MaxQueueSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_HasNoStaticMutableState()
|
||||
{
|
||||
// Garde-fou anti-régression (§13/§28) : la config du pont externe ne doit
|
||||
// être pilotée par AUCUN champ static mutable (DI-friendly, isolable, testable).
|
||||
string[] staticMutable = typeof(ExternalObservabilityOptions)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.Where(f => !f.IsInitOnly && !f.IsLiteral)
|
||||
.Select(f => f.Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(staticMutable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Liste blanche et filtrage sécurité du mapping externe (§31/§79/§71).</summary>
|
||||
public class OmikaEventExternalMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapsWhitelistFields_AndExcludesIdentity()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Level = TelemetryLevel.Warning,
|
||||
Priority = TelemetryPriority.P2,
|
||||
Component = "SyncEngine",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
OperationId = Guid.NewGuid(),
|
||||
AppVersion = "1.2.3",
|
||||
Platform = "Android",
|
||||
ErrorCode = "ERR-1",
|
||||
DurationMs = 123,
|
||||
Success = true,
|
||||
DeviceId = "device-abc",
|
||||
ClientId = "client-1",
|
||||
UserId = "user-1",
|
||||
CollaborateurId = "col-1",
|
||||
EntityId = "entity-1"
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(e);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(LogLevel.Warning, record.Level);
|
||||
Assert.Equal(TelemetryEventCodes.MobileAppStarted.Value, record.EventCode);
|
||||
Assert.Equal(e.TraceId, record.TraceId);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.trace_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.session_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.operation_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "component" && (string?)kv.Value == "SyncEngine");
|
||||
|
||||
// Identité métier : JAMAIS exportée (liste blanche externe).
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "omika.device_id");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "userid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "clientid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "collaborateurid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "entityid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsForbiddenMetadataKeysAndValues()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Metadata = TelemetryMetadata.Create(
|
||||
("ok_key", "safe-value"),
|
||||
("password", "hunter2"),
|
||||
("Authorization", "Bearer secret"),
|
||||
("connectionString", "Server=..." ),
|
||||
("note", "contains token here"))
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(e);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "ok_key");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "password");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "Authorization");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "connectionString");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsNullForInvalidEvent()
|
||||
{
|
||||
Assert.Null(OmikaEventExternalMapper.ToLogRecord((OmikaEvent?)null));
|
||||
|
||||
// EventCode invalide (défaut) → événement non mappable.
|
||||
var e = new TelemetryEvent();
|
||||
Assert.Null(OmikaEventExternalMapper.ToLogRecord(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogLevelMapping_IsDeterministic()
|
||||
{
|
||||
Assert.Equal(LogLevel.Trace, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Trace));
|
||||
Assert.Equal(LogLevel.Debug, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Debug));
|
||||
Assert.Equal(LogLevel.Information, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Info));
|
||||
Assert.Equal(LogLevel.Warning, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Warning));
|
||||
Assert.Equal(LogLevel.Error, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Error));
|
||||
Assert.Equal(LogLevel.Critical, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Critical));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsDto_WithWhitelist()
|
||||
{
|
||||
var dto = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-SYNC-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Error",
|
||||
Priority = "P1",
|
||||
Component = "MobileSync",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
DeviceId = "device-abc",
|
||||
ClientId = "client-1",
|
||||
ErrorCode = "ERR-X",
|
||||
Metadata = new Dictionary<string, object?> { ["k"] = "v", ["password"] = "secret" }
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(dto);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(LogLevel.Error, record.Level);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.trace_id");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "deviceid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "password");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "k");
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// §5C.1C — Le source fichier du RemoteDiagnosticForwarder applique la même
|
||||
/// protection partial-line que le reader du Worker (via JsonlCompleteLineReader).
|
||||
/// </summary>
|
||||
public class RemoteDiagnosticFileForwarderSourceTests
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void PartialFinalLine_IsNotReturned_AndDoesNotAdvance()
|
||||
{
|
||||
string dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
string line1 = Serialize(NewRecord(Guid.NewGuid()));
|
||||
string line2 = Serialize(NewRecord(Guid.NewGuid())); // fragment sans newline
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl"),
|
||||
line1 + "\n" + line2,
|
||||
Utf8NoBom);
|
||||
|
||||
var source = new RemoteDiagnosticFileForwarderSource(
|
||||
new RemoteDiagnosticOptions { Directory = dir },
|
||||
"forwarder-checkpoint.json");
|
||||
|
||||
RemoteDiagnosticForwardReadResult read = source.ReadNext(null, 10);
|
||||
|
||||
Assert.Single(read.Records); // seul line1 (complète)
|
||||
Assert.Equal(0, read.NextPosition!.LineIndex); // fragment non checkpointé
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePoisonLine_IsSkipped_AndCursorAdvances()
|
||||
{
|
||||
string dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
string line = Serialize(NewRecord(Guid.NewGuid()));
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl"),
|
||||
"{ json invalide\n" + line + "\n",
|
||||
Utf8NoBom);
|
||||
|
||||
var source = new RemoteDiagnosticFileForwarderSource(
|
||||
new RemoteDiagnosticOptions { Directory = dir },
|
||||
"forwarder-checkpoint.json");
|
||||
|
||||
RemoteDiagnosticForwardReadResult read = source.ReadNext(null, 10);
|
||||
|
||||
Assert.Single(read.Records);
|
||||
Assert.Equal(1, read.NextPosition!.LineIndex); // poison (0) sautée, ligne (1)
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticRecord NewRecord(Guid eventId) => new()
|
||||
{
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow,
|
||||
BatchId = Guid.NewGuid(),
|
||||
IngestionTraceId = Guid.NewGuid().ToString("D"),
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = "TEST",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P2"
|
||||
}
|
||||
};
|
||||
|
||||
private static string Serialize(RemoteDiagnosticRecord record) =>
|
||||
JsonSerializer.Serialize(record, JsonOptions);
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-fwd-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>RemoteDiagnosticForwarder (§6/§40/§41/§42) : durable, checkpoint, doublons.</summary>
|
||||
public class RemoteDiagnosticForwarderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ForwardsAll_AndAdvancesCheckpoint()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
source.Add(Guid.NewGuid());
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.True(progressed);
|
||||
Assert.Equal(3, sink.Received.Count);
|
||||
Assert.Equal(1, source.CommitCount);
|
||||
Assert.NotNull(source.Checkpoint);
|
||||
Assert.Equal(2, source.Checkpoint!.LineIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotAdvanceWhenCircuitOpen()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink { Behavior = _ => 0 }; // drop (infra down)
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30));
|
||||
circuit.RecordFailure(); // circuit ouvert
|
||||
|
||||
var forwarder = NewForwarder(source, sink, circuit);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(0, source.CommitCount); // pas d'avancement : on réessaiera plus tard
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvancesOverPolicyDroppedBatch()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
// La donnée est "consommée" par politique (sécurité/invalide) sans panne infra :
|
||||
// le forwarder doit avancer pour ne pas rejouer indéfiniment.
|
||||
var sink = new RecordingSink { Behavior = _ => 0 };
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(1, source.CommitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtLeastOnce_ProducesDuplicates_WhenCheckpointLost()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
Guid eventId = Guid.NewGuid();
|
||||
source.Add(eventId);
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
await forwarder.FlushAsync();
|
||||
Assert.Equal(2, sink.Received.Count);
|
||||
|
||||
// Checkpoint perdu (redémarrage/panne) : at-least-once → réexpédition (doublons).
|
||||
source.Checkpoint = null;
|
||||
await forwarder.FlushAsync();
|
||||
|
||||
Assert.Equal(4, sink.Received.Count);
|
||||
Assert.Equal(2, sink.Received.Count(r => r.EventId == eventId));
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticForwarder NewForwarder(
|
||||
FakeForwarderSource source,
|
||||
RecordingSink sink,
|
||||
out ExternalCircuitBreaker circuit)
|
||||
{
|
||||
circuit = new ExternalCircuitBreaker(3, TimeSpan.FromSeconds(30));
|
||||
return NewForwarder(source, sink, circuit);
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticForwarder NewForwarder(
|
||||
FakeForwarderSource source,
|
||||
RecordingSink sink,
|
||||
ExternalCircuitBreaker circuit)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
ForwarderBatchSize = 100
|
||||
};
|
||||
|
||||
return new RemoteDiagnosticForwarder(
|
||||
source,
|
||||
sink,
|
||||
options,
|
||||
new ExternalObservabilityCounters(),
|
||||
circuit);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IExternalDiagnosticSink
|
||||
{
|
||||
public List<DiagnosticEventDto> Received { get; } = [];
|
||||
|
||||
public Func<IReadOnlyList<DiagnosticEventDto>, int>? Behavior { get; set; }
|
||||
|
||||
public Task<int> ForwardAsync(IReadOnlyList<DiagnosticEventDto> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Received.AddRange(events);
|
||||
return Task.FromResult(Behavior is null ? events.Count : Behavior(events));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeForwarderSource : IRemoteDiagnosticForwarderSource
|
||||
{
|
||||
private readonly List<RemoteDiagnosticRecord> _records = [];
|
||||
|
||||
public RemoteDiagnosticForwarderPosition? Checkpoint { get; set; }
|
||||
|
||||
public int CommitCount { get; private set; }
|
||||
|
||||
public void Add(Guid eventId) => _records.Add(new RemoteDiagnosticRecord
|
||||
{
|
||||
Event = new DiagnosticEventDto { EventId = eventId, EventCode = "OMK-MOB-SYNC-001" }
|
||||
});
|
||||
|
||||
public RemoteDiagnosticForwarderPosition? LoadCheckpoint() => Checkpoint;
|
||||
|
||||
public RemoteDiagnosticForwardReadResult ReadNext(RemoteDiagnosticForwarderPosition? after, int maxCount)
|
||||
{
|
||||
int start = after is null ? 0 : Math.Min(_records.Count, after.LineIndex + 1);
|
||||
int take = Math.Min(maxCount, _records.Count - start);
|
||||
var records = _records.Skip(start).Take(take).ToList();
|
||||
|
||||
return new RemoteDiagnosticForwardReadResult
|
||||
{
|
||||
Records = records,
|
||||
NextPosition = take == 0
|
||||
? null
|
||||
: new RemoteDiagnosticForwarderPosition { FileName = "mem", LineIndex = start + take - 1 },
|
||||
ReachedEnd = start + take >= _records.Count
|
||||
};
|
||||
}
|
||||
|
||||
public void Commit(RemoteDiagnosticForwarderPosition position)
|
||||
{
|
||||
Checkpoint = position;
|
||||
CommitCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Omika.Core.Observability.Forwarding;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Forwarding;
|
||||
|
||||
/// <summary>Tests du client HTTP du forwarder (mapping de statuts, headers, jamais de levée).</summary>
|
||||
public class DiagnosticsForwarderClientTests
|
||||
{
|
||||
private static DiagnosticsForwarderClient NewClient(FakeHandler handler) =>
|
||||
new(new HttpClient(handler), "https://diagnostics.example/api/v1/diagnostics/batches", TimeSpan.FromSeconds(5));
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_2xx_ReturnsSuccess_WithAcceptedCount()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, "{\"batchId\":\"00000000-0000-0000-0000-000000000000\",\"acceptedCount\":7}");
|
||||
var client = NewClient(handler);
|
||||
|
||||
DiagnosticsForwardResult result = await client.SendAsync(Batch(), "BATIK.01", "omkdiag_x");
|
||||
|
||||
Assert.Equal(DiagnosticsForwardOutcome.Success, result.Outcome);
|
||||
Assert.Equal(7, result.AcceptedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_401_ReturnsUnauthorized() =>
|
||||
await AssertOutcomeAsync(HttpStatusCode.Unauthorized, DiagnosticsForwardOutcome.Unauthorized);
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_403_ReturnsForbidden() =>
|
||||
await AssertOutcomeAsync(HttpStatusCode.Forbidden, DiagnosticsForwardOutcome.Forbidden);
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_400_ReturnsRejected() =>
|
||||
await AssertOutcomeAsync(HttpStatusCode.BadRequest, DiagnosticsForwardOutcome.Rejected);
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_503_ReturnsTransient() =>
|
||||
await AssertOutcomeAsync(HttpStatusCode.ServiceUnavailable, DiagnosticsForwardOutcome.Transient);
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SetsClientCodeAndKeyHeaders()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, "{}");
|
||||
var client = NewClient(handler);
|
||||
|
||||
await client.SendAsync(Batch(), "BATIK.01", "omkdiag_secret_key");
|
||||
|
||||
Assert.Equal("BATIK.01", handler.LastRequest?.Headers.GetValues("X-Omika-Client-Code").FirstOrDefault());
|
||||
Assert.Equal("omkdiag_secret_key", handler.LastRequest?.Headers.GetValues("X-Omika-Diagnostics-Key").FirstOrDefault());
|
||||
}
|
||||
|
||||
private static async Task AssertOutcomeAsync(HttpStatusCode status, DiagnosticsForwardOutcome expected)
|
||||
{
|
||||
var handler = new FakeHandler(status, "{}");
|
||||
var client = NewClient(handler);
|
||||
|
||||
DiagnosticsForwardResult result = await client.SendAsync(Batch(), "BATIK.01", "omkdiag_x");
|
||||
|
||||
Assert.Equal(expected, result.Outcome);
|
||||
}
|
||||
|
||||
private static CentralDiagnosticBatchDto Batch() =>
|
||||
new() { Events = [ForwardingTestHarness.NewRecord()] };
|
||||
|
||||
private sealed class FakeHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
|
||||
public FakeHandler(HttpStatusCode status, string body)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent(_body, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Omika.Core.Observability.Forwarding;
|
||||
|
||||
namespace Omika.Tests.Observability.Forwarding;
|
||||
|
||||
/// <summary>Tests du spool disque du forwarder (checkpoint, bornes, suppression, quota, rétention).</summary>
|
||||
public class DiagnosticsForwarderSpoolTests : IDisposable
|
||||
{
|
||||
private const string File001 = "omika-mobile-diagnostic-20261002-001.jsonl";
|
||||
private const string File002 = "omika-mobile-diagnostic-20261002-002.jsonl";
|
||||
private const string File003 = "omika-mobile-diagnostic-20261002-003.jsonl";
|
||||
|
||||
private readonly string _dir = ForwardingTestHarness.CreateTempDirectory();
|
||||
|
||||
private static DiagnosticsForwardingSpoolOptions DefaultSpool => new();
|
||||
|
||||
public void Dispose() => ForwardingTestHarness.DeleteDirectory(_dir);
|
||||
|
||||
private DiagnosticsForwarderSpool NewSpool(DiagnosticsForwardingSpoolOptions? options = null) =>
|
||||
new(_dir, "forwarder-checkpoint.json", options ?? DefaultSpool);
|
||||
|
||||
[Fact]
|
||||
public void ReadNext_EmptyDirectory_ReturnsReachedEnd()
|
||||
{
|
||||
var spool = NewSpool();
|
||||
|
||||
DiagnosticsForwarderBatch batch = spool.ReadNext(null, 10, 1024);
|
||||
|
||||
Assert.Empty(batch.Records);
|
||||
Assert.True(batch.ReachedEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadNext_ReadsRecordsInOrder_AndResumesAcrossFiles()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001,
|
||||
[ForwardingTestHarness.NewRecord(component: "a"), ForwardingTestHarness.NewRecord(component: "b")]);
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File002,
|
||||
[ForwardingTestHarness.NewRecord(component: "c")]);
|
||||
|
||||
var spool = NewSpool();
|
||||
|
||||
DiagnosticsForwarderBatch first = spool.ReadNext(null, 2, 1_000_000);
|
||||
Assert.Equal(2, first.Records.Count);
|
||||
Assert.Equal("a", first.Records[0].Event.Component);
|
||||
Assert.Equal("b", first.Records[1].Event.Component);
|
||||
Assert.NotNull(first.NextPosition);
|
||||
Assert.Equal(File001, first.NextPosition!.FileName);
|
||||
|
||||
DiagnosticsForwarderBatch second = spool.ReadNext(first.NextPosition, 2, 1_000_000);
|
||||
Assert.Single(second.Records);
|
||||
Assert.Equal("c", second.Records[0].Event.Component);
|
||||
Assert.Equal(File002, second.NextPosition!.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadNext_RespectsMaxBytes_StillReturnsAtLeastOneRecord()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001,
|
||||
[ForwardingTestHarness.NewRecord(), ForwardingTestHarness.NewRecord()]);
|
||||
|
||||
var spool = NewSpool();
|
||||
|
||||
DiagnosticsForwarderBatch batch = spool.ReadNext(null, 100, 10);
|
||||
|
||||
Assert.Single(batch.Records); // premier seuil (octets) : 1 seul, jamais zéro.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Commit_ThenLoadCheckpoint_ResumesFromNextLine()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001,
|
||||
[ForwardingTestHarness.NewRecord(component: "a"), ForwardingTestHarness.NewRecord(component: "b"), ForwardingTestHarness.NewRecord(component: "c")]);
|
||||
|
||||
var spool = NewSpool();
|
||||
|
||||
DiagnosticsForwarderBatch first = spool.ReadNext(null, 1, 1_000_000);
|
||||
Assert.Single(first.Records);
|
||||
Assert.Equal("a", first.Records[0].Event.Component);
|
||||
spool.Commit(first.NextPosition!);
|
||||
|
||||
DiagnosticsForwarderPosition? checkpoint = spool.LoadCheckpoint();
|
||||
Assert.NotNull(checkpoint);
|
||||
Assert.Equal(File001, checkpoint!.FileName);
|
||||
Assert.Equal(0, checkpoint.LineIndex);
|
||||
|
||||
DiagnosticsForwarderBatch second = spool.ReadNext(checkpoint, 10, 1_000_000);
|
||||
Assert.Equal(2, second.Records.Count);
|
||||
Assert.Equal("b", second.Records[0].Event.Component);
|
||||
Assert.Equal("c", second.Records[1].Event.Component);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PruneConsumed_DeletesOlderFiles_NeverActiveFile()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File002, [ForwardingTestHarness.NewRecord()]);
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File003, [ForwardingTestHarness.NewRecord()]);
|
||||
|
||||
var spool = NewSpool();
|
||||
|
||||
int deleted = spool.PruneConsumed(new DiagnosticsForwarderPosition { FileName = File002, LineIndex = 0 });
|
||||
|
||||
Assert.Equal(1, deleted); // seul 001 (antérieur) est supprimé.
|
||||
Assert.False(File.Exists(Path.Combine(_dir, File001)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File002)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File003))); // actif préservé.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceBounds_Quota_DeletesOldestFiles()
|
||||
{
|
||||
int chunk = 400 * 1024; // 3 × 400 Ko = 1.2 Mo > 1 Mo de quota.
|
||||
string padding = new string('a', chunk);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File001, padding);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File002, padding);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File003, padding);
|
||||
|
||||
var spool = NewSpool(new DiagnosticsForwardingSpoolOptions { MaxTotalSizeMb = 1 });
|
||||
|
||||
int deleted = spool.EnforceBounds(DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, deleted); // le plus ancien est évincé, total ≤ 1 Mo.
|
||||
Assert.False(File.Exists(Path.Combine(_dir, File001)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File002)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File003)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceBounds_Retention_DeletesExpiredFiles_NotActive()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File002, [ForwardingTestHarness.NewRecord()]);
|
||||
|
||||
// 001 est ancien, 002 est l'actif (et récent).
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, File001), DateTime.UtcNow.AddDays(-2));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, File002), DateTime.UtcNow);
|
||||
|
||||
var spool = NewSpool(new DiagnosticsForwardingSpoolOptions { RetentionDays = 1, MaxTotalSizeMb = 1024 });
|
||||
|
||||
int deleted = spool.EnforceBounds(DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
Assert.False(File.Exists(Path.Combine(_dir, File001)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File002)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Omika.Core.Observability.Deployment;
|
||||
using Omika.Core.Observability.Forwarding;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Forwarding;
|
||||
|
||||
/// <summary>Tests de l'orchestrateur du forwarder (ACK → checkpoint/suppression, erreurs, reprise).</summary>
|
||||
public class DiagnosticsForwarderTests : IDisposable
|
||||
{
|
||||
private const string File001 = "omika-mobile-diagnostic-20261002-001.jsonl";
|
||||
private const string File002 = "omika-mobile-diagnostic-20261002-002.jsonl";
|
||||
private const string File003 = "omika-mobile-diagnostic-20261002-003.jsonl";
|
||||
|
||||
private readonly string _dir = ForwardingTestHarness.CreateTempDirectory();
|
||||
|
||||
public void Dispose() => ForwardingTestHarness.DeleteDirectory(_dir);
|
||||
|
||||
private static DiagnosticsForwardingOptions Options(int batchSize = 100) =>
|
||||
new() { Enabled = true, Endpoint = "https://diagnostics.example/api/v1/diagnostics/batches", ApiKey = "omkdiag_x", BatchSize = batchSize, MaxBatchBytes = 1024 * 1024 };
|
||||
|
||||
private DiagnosticsForwarderSpool NewSpool() =>
|
||||
new(_dir, "forwarder-checkpoint.json", new DiagnosticsForwardingSpoolOptions());
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_Success_CommitsCheckpoint()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
new FakeClient(DiagnosticsForwardOutcome.Success, accepted: 1),
|
||||
Options(),
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.True(progressed);
|
||||
Assert.NotNull(NewSpool().LoadCheckpoint());
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File001))); // fichier actif non supprimé.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_Success_DrainsAndPrunesOlderFiles()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File002, [ForwardingTestHarness.NewRecord()]);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
new FakeClient(DiagnosticsForwardOutcome.Success, accepted: 1),
|
||||
Options(batchSize: 1),
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
Assert.True(await forwarder.FlushAsync()); // 001 forwardé
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File001)));
|
||||
|
||||
Assert.True(await forwarder.FlushAsync()); // 002 forwardé → 001 purgé
|
||||
Assert.False(File.Exists(Path.Combine(_dir, File001)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File002)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_Unauthorized_DoesNotCommit_KeepsSpool()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
new FakeClient(DiagnosticsForwardOutcome.Unauthorized),
|
||||
Options(),
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Null(NewSpool().LoadCheckpoint()); // aucun avancement.
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File001))); // spool conservé.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_Rejected_AdvancesCheckpoint_DropsBatch()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
new FakeClient(DiagnosticsForwardOutcome.Rejected),
|
||||
Options(),
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.NotNull(NewSpool().LoadCheckpoint()); // avancé pour ne pas rejouer indéfiniment.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_BacklogOverQuota_StillEnforcesBounds()
|
||||
{
|
||||
// T22 : le quota doit borner le spool même pendant une panne centrale
|
||||
// (client Transient = jamais d'ACK → le spool n'est jamais « vide »).
|
||||
int chunk = 400 * 1024; // 3 × 400 Ko = 1.2 Mo > 1 Mo de quota.
|
||||
string padding = new string('a', chunk);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File001, padding);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File002, padding);
|
||||
ForwardingTestHarness.WriteRawSpoolFile(_dir, File003, padding);
|
||||
|
||||
var spool = new DiagnosticsForwarderSpool(
|
||||
_dir,
|
||||
"forwarder-checkpoint.json",
|
||||
new DiagnosticsForwardingSpoolOptions { MaxTotalSizeMb = 1 });
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
spool,
|
||||
new FakeClient(DiagnosticsForwardOutcome.Transient),
|
||||
Options(),
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.False(File.Exists(Path.Combine(_dir, File001))); // plus ancien évincé par quota (sans ACK).
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File002)));
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File003))); // actif préservé.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_NoClientCode_DoesNotSend()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var client = new FakeClient(DiagnosticsForwardOutcome.Success, accepted: 1);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
client,
|
||||
Options(),
|
||||
new FakeDeployment(null),
|
||||
NullLogger<DiagnosticsForwarder>.Instance);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(0, client.SendCount);
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File001)));
|
||||
}
|
||||
|
||||
// ------------------------- T20 §38 : Production + HTTP central refusé ----
|
||||
[Fact]
|
||||
public async Task ForwardOnce_HttpEndpointInProduction_RefusesSend_KeepsSpool()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var client = new FakeClient(DiagnosticsForwardOutcome.Success, accepted: 1);
|
||||
var options = new DiagnosticsForwardingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "http://diagnostics.example/api/v1/diagnostics/batches",
|
||||
ApiKey = "omkdiag_x",
|
||||
BatchSize = 100,
|
||||
MaxBatchBytes = 1024 * 1024
|
||||
};
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
client,
|
||||
options,
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance,
|
||||
isProduction: true);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(0, client.SendCount); // aucun envoi réseau.
|
||||
Assert.Null(NewSpool().LoadCheckpoint()); // spool conservé, checkpoint inchangé.
|
||||
Assert.True(File.Exists(Path.Combine(_dir, File001)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnce_HttpsEndpointInProduction_Sends()
|
||||
{
|
||||
ForwardingTestHarness.WriteSpoolFile(_dir, File001, [ForwardingTestHarness.NewRecord()]);
|
||||
var client = new FakeClient(DiagnosticsForwardOutcome.Success, accepted: 1);
|
||||
var forwarder = new DiagnosticsForwarder(
|
||||
NewSpool(),
|
||||
client,
|
||||
Options(), // https://
|
||||
new FakeDeployment("BATIK.01"),
|
||||
NullLogger<DiagnosticsForwarder>.Instance,
|
||||
isProduction: true);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.True(progressed);
|
||||
Assert.Equal(1, client.SendCount);
|
||||
}
|
||||
|
||||
// ------------------------- Options : règle Production/HTTPS (pure) ----
|
||||
[Theory]
|
||||
[InlineData("http://diagnostics.example/x", true, false)]
|
||||
[InlineData("https://diagnostics.example/x", true, true)]
|
||||
[InlineData("http://diagnostics.example/x", false, true)]
|
||||
[InlineData("https://diagnostics.example/x", false, true)]
|
||||
[InlineData("", true, false)]
|
||||
public void Options_IsAllowedForEnvironment(string endpoint, bool isProduction, bool expected)
|
||||
{
|
||||
var options = new DiagnosticsForwardingOptions { Endpoint = endpoint };
|
||||
Assert.Equal(expected, options.IsAllowedForEnvironment(isProduction));
|
||||
}
|
||||
|
||||
private sealed class FakeClient(DiagnosticsForwardOutcome outcome, int accepted = 0) : IDiagnosticsForwarderClient
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
|
||||
public Task<DiagnosticsForwardResult> SendAsync(
|
||||
CentralDiagnosticBatchDto batch,
|
||||
string clientCode,
|
||||
string apiKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SendCount++;
|
||||
return Task.FromResult(new DiagnosticsForwardResult(outcome, accepted));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeDeployment(string? clientCode) : IDiagnosticDeploymentContextProvider
|
||||
{
|
||||
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new DiagnosticDeploymentContext { ClientCode = clientCode });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Forwarding;
|
||||
|
||||
/// <summary>Fabrique de test partagée pour le forwarder T19.</summary>
|
||||
internal static class ForwardingTestHarness
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-forwarding-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 RemoteDiagnosticRecord NewRecord(
|
||||
string clientCode = "BATIK.01",
|
||||
string component = "test",
|
||||
Guid? eventId = null,
|
||||
string? screenCode = null,
|
||||
string? featureCode = null) =>
|
||||
new()
|
||||
{
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow,
|
||||
BatchId = Guid.NewGuid(),
|
||||
IngestionTraceId = Guid.NewGuid().ToString("D"),
|
||||
ClientCode = clientCode,
|
||||
ActorKey = "a" + new string('0', 63),
|
||||
ActorKeyVersion = 1,
|
||||
DeviceKey = "d" + new string('0', 63),
|
||||
DeviceKeyVersion = 1,
|
||||
LicenseNumber = "b600a641d61c485b8e928cd3aab281b1",
|
||||
ServerInstance = "API-SRV-01",
|
||||
Environment = "Production",
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3",
|
||||
Component = component,
|
||||
ScreenCode = screenCode,
|
||||
FeatureCode = featureCode
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Sérialise un enregistrement au format JSONL canonique (camelCase).</summary>
|
||||
public static string Serialize(RemoteDiagnosticRecord record) =>
|
||||
JsonSerializer.Serialize(record, JsonOptions);
|
||||
|
||||
/// <summary>Écrit un fichier JSONL de spool (nom exact, ex. omika-mobile-diagnostic-20261002-001.jsonl).</summary>
|
||||
public static void WriteSpoolFile(string directory, string fileName, IReadOnlyList<RemoteDiagnosticRecord> records)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllLines(Path.Combine(directory, fileName), records.Select(Serialize));
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier de spool avec un contenu brut arbitraire (tests quota/taille).</summary>
|
||||
public static void WriteRawSpoolFile(string directory, string fileName, string content)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(Path.Combine(directory, fileName), content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class NullTelemetryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Track_NeverThrows()
|
||||
{
|
||||
NullTelemetryService service = NullTelemetryService.Instance;
|
||||
|
||||
service.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted });
|
||||
// Aucune exception attendue : l'assertion porte sur l'absence de levée.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_NeverThrows()
|
||||
{
|
||||
NullTelemetryService service = NullTelemetryService.Instance;
|
||||
|
||||
service.Log(new DiagnosticEvent { EventCode = TelemetryEventCodes.ApiRequestFailed });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_NullEvent_NeverThrows()
|
||||
{
|
||||
NullTelemetryService.Instance.Track(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_NullEvent_NeverThrows()
|
||||
{
|
||||
NullTelemetryService.Instance.Log(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_IsSharedSingleton()
|
||||
{
|
||||
Assert.Same(NullTelemetryService.Instance, NullTelemetryService.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class ObservabilityCompositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddOmikaTelemetry_ResolvesNullTelemetryService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaTelemetry();
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
var telemetry = provider.GetRequiredService<IOmikaTelemetry>();
|
||||
Assert.IsType<NullTelemetryService>(telemetry);
|
||||
Assert.Same(telemetry, provider.GetRequiredService<IEventTracker>());
|
||||
Assert.Same(telemetry, provider.GetRequiredService<IDiagnosticLogger>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaTelemetrySink_ResolvesFullPipeline()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaTelemetrySink();
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.IsType<SafeTelemetrySink>(provider.GetRequiredService<IOmikaTelemetry>());
|
||||
Assert.IsType<NullTelemetryExporter>(provider.GetRequiredService<ITelemetryExporter>());
|
||||
Assert.NotNull(provider.GetRequiredService<PriorityTelemetryQueue>());
|
||||
Assert.NotNull(provider.GetRequiredService<TelemetryWorker>());
|
||||
Assert.NotNull(provider.GetRequiredService<TelemetryCounters>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaTelemetrySink_AppliesConfigure()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaTelemetrySink(o => o.Enabled = false);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IOmikaTelemetry telemetry = provider.GetRequiredService<IOmikaTelemetry>();
|
||||
var sink = Assert.IsType<SafeTelemetrySink>(telemetry);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
Assert.Equal(1L, sink.Counters.DroppedDisabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Contrat structurel de la façade d'observabilité : les méthodes exposées au
|
||||
/// métier sont strictement <c>void</c> (never-throw, non bloquantes, sans await).
|
||||
/// Aucune méthode <c>Task ...Async</c> ne doit apparaître.
|
||||
/// </summary>
|
||||
public class ObservabilityContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllTelemetryInterfaces_ExposeOnlyVoidMethods()
|
||||
{
|
||||
Type[] interfaces = [typeof(IOmikaTelemetry), typeof(IEventTracker), typeof(IDiagnosticLogger)];
|
||||
|
||||
foreach (Type type in interfaces)
|
||||
{
|
||||
foreach (MethodInfo method in type.GetMethods())
|
||||
{
|
||||
Assert.True(
|
||||
method.ReturnType == typeof(void),
|
||||
$"{type.Name}.{method.Name} doit retourner void (jamais Task).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoAsyncTrackOrLogMethod_IsExposed()
|
||||
{
|
||||
MethodInfo[] methods = typeof(IEventTracker).GetMethods()
|
||||
.Concat(typeof(IDiagnosticLogger).GetMethods())
|
||||
.Concat(typeof(IOmikaTelemetry).GetMethods())
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(methods, m => m.Name.EndsWith("Async", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventTracker_ExposesTrack_ReturningVoid()
|
||||
{
|
||||
MethodInfo track = typeof(IEventTracker).GetMethod(nameof(IEventTracker.Track))!;
|
||||
|
||||
Assert.NotNull(track);
|
||||
Assert.Equal(typeof(void), track.ReturnType);
|
||||
Assert.Equal(typeof(TelemetryEvent), track.GetParameters().Single().ParameterType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticLogger_ExposesLog_ReturningVoid()
|
||||
{
|
||||
MethodInfo log = typeof(IDiagnosticLogger).GetMethod(nameof(IDiagnosticLogger.Log))!;
|
||||
|
||||
Assert.NotNull(log);
|
||||
Assert.Equal(typeof(void), log.ReturnType);
|
||||
Assert.Equal(typeof(DiagnosticEvent), log.GetParameters().Single().ParameterType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Fabrique de test partagée pour le pipeline d'observabilité.
|
||||
/// </summary>
|
||||
internal static class ObservabilityTestHarness
|
||||
{
|
||||
public static (SafeTelemetrySink Sink, PriorityTelemetryQueue Queue, TelemetryCounters Counters)
|
||||
CreatePipeline(ObservabilityOptions? options = null)
|
||||
{
|
||||
ObservabilityOptions opts = options ?? new ObservabilityOptions();
|
||||
var counters = new TelemetryCounters();
|
||||
var queue = new PriorityTelemetryQueue(opts, counters);
|
||||
var sink = new SafeTelemetrySink(opts, queue, counters);
|
||||
return (sink, queue, counters);
|
||||
}
|
||||
|
||||
public static TelemetryEvent NewEvent(
|
||||
EventCode code,
|
||||
TelemetryLevel level = TelemetryLevel.Info,
|
||||
TelemetryPriority? priority = null,
|
||||
TelemetryMetadata? metadata = null) =>
|
||||
new()
|
||||
{
|
||||
EventCode = code,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
public static DiagnosticEvent NewDiagnostic(
|
||||
EventCode code,
|
||||
TelemetryLevel level = TelemetryLevel.Info,
|
||||
TelemetryPriority? priority = null) =>
|
||||
new()
|
||||
{
|
||||
EventCode = code,
|
||||
Level = level,
|
||||
Priority = priority
|
||||
};
|
||||
|
||||
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,80 @@
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Tests.Observability.Forwarding;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la validation du lot central (réutilisation de la whitelist Phase 4).</summary>
|
||||
public class CentralDiagnosticBatchValidatorTests
|
||||
{
|
||||
private static CentralDiagnosticOptions Limits() => new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidBatch_ReturnsValid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto { Events = [ForwardingTestHarness.NewRecord()] };
|
||||
|
||||
CentralDiagnosticValidationResult result = CentralDiagnosticBatchValidator.Validate(batch, Limits());
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullBatch_Invalid() =>
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(null, Limits()).IsValid);
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyEvents_Invalid() =>
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto(), Limits()).IsValid);
|
||||
|
||||
[Fact]
|
||||
public void Validate_TooManyEvents_Invalid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto
|
||||
{
|
||||
Events = Enumerable.Range(0, 101).Select(_ => ForwardingTestHarness.NewRecord()).ToList()
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(batch, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullEvent_Invalid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto { Events = [(RemoteDiagnosticRecord)null!] };
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(batch, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidEventCode_Invalid()
|
||||
{
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord();
|
||||
var bad = new RemoteDiagnosticRecord
|
||||
{
|
||||
ReceivedAtUtc = record.ReceivedAtUtc,
|
||||
Event = new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = string.Empty }
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto { Events = [bad] }, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ForbiddenMetadataKey_Invalid()
|
||||
{
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord();
|
||||
var bad = new RemoteDiagnosticRecord
|
||||
{
|
||||
ReceivedAtUtc = record.ReceivedAtUtc,
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["password"] = "x" }
|
||||
}
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto { Events = [bad] }, Limits()).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Tests.Observability.Forwarding;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du spool central (ClientCode autoritaire écrasé, champs préservés, bornes).</summary>
|
||||
public class CentralDiagnosticIngestionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = ForwardingTestHarness.CreateTempDirectory();
|
||||
|
||||
public void Dispose() => ForwardingTestHarness.DeleteDirectory(_dir);
|
||||
|
||||
private CentralDiagnosticIngestionStore NewStore() =>
|
||||
new(new CentralDiagnosticOptions { Directory = _dir });
|
||||
|
||||
[Fact]
|
||||
public async Task Store_WritesAuthoritativeClientCode_OverridingPayload()
|
||||
{
|
||||
var store = NewStore();
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord(clientCode: "AUTRECLIENT");
|
||||
|
||||
int accepted = await store.StoreAsync([record], "BATIK.01", "trace-id");
|
||||
|
||||
Assert.Equal(1, accepted);
|
||||
string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
Assert.Equal("BATIK.01", document.RootElement.GetProperty("clientCode").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_PreservesCanonicalFields()
|
||||
{
|
||||
var store = NewStore();
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord(
|
||||
clientCode: "BATIK.01", component: "telemetry-core", screenCode: "AGENDA", featureCode: "AGENDA_REFRESH");
|
||||
|
||||
await store.StoreAsync([record], "BATIK.01", "trace-id");
|
||||
|
||||
string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))));
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
Assert.Equal(record.ActorKey, root.GetProperty("actorKey").GetString());
|
||||
Assert.Equal(record.DeviceKey, root.GetProperty("deviceKey").GetString());
|
||||
Assert.Equal(record.ServerInstance, root.GetProperty("serverInstance").GetString());
|
||||
Assert.Equal(record.Environment, root.GetProperty("environment").GetString());
|
||||
Assert.Equal(record.BatchId.ToString(), root.GetProperty("batchId").GetString());
|
||||
|
||||
JsonElement evt = root.GetProperty("event");
|
||||
Assert.Equal("telemetry-core", evt.GetProperty("component").GetString());
|
||||
Assert.Equal("AGENDA", evt.GetProperty("screenCode").GetString());
|
||||
Assert.Equal("AGENDA_REFRESH", evt.GetProperty("featureCode").GetString());
|
||||
// Identités brutes interdites : jamais dans le spool central.
|
||||
Assert.False(evt.TryGetProperty("deviceId", out _));
|
||||
Assert.False(evt.TryGetProperty("clientId", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_NoDirectory_Throws()
|
||||
{
|
||||
var store = new CentralDiagnosticIngestionStore(new CentralDiagnosticOptions { Directory = string.Empty });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.StoreAsync([ForwardingTestHarness.NewRecord()], "BATIK.01", "trace-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_EmptyClientCode_Throws()
|
||||
{
|
||||
var store = NewStore();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.StoreAsync([ForwardingTestHarness.NewRecord()], " ", "trace-id"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du fan-out : l'échec d'un exporteur n'empêche jamais les autres (§16).</summary>
|
||||
public class CompositeTelemetryExporterTests
|
||||
{
|
||||
private sealed class RecordingExporter : ITelemetryExporter
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public bool Throw { get; set; }
|
||||
|
||||
public Task ExportAsync(IReadOnlyList<OmikaEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
if (Throw)
|
||||
{
|
||||
throw new InvalidOperationException("exporter down");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneExporterFails_OtherStillRuns()
|
||||
{
|
||||
var broken = new RecordingExporter { Throw = true };
|
||||
var healthy = new RecordingExporter();
|
||||
var composite = new CompositeTelemetryExporter([broken, healthy]);
|
||||
|
||||
await composite.ExportAsync([OutboxTestHarness.NewEvent()], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, broken.Calls);
|
||||
Assert.Equal(1, healthy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyExporterList_NoOp()
|
||||
{
|
||||
var composite = new CompositeTelemetryExporter([]);
|
||||
await composite.ExportAsync([OutboxTestHarness.NewEvent()], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Garde-fous d'architecture (§53, §84) : isolation métier, absence d'infra externe.</summary>
|
||||
public class DiagnosticArchitectureTests
|
||||
{
|
||||
[Fact]
|
||||
public void DiagnosticOutbox_HasNoReferenceToBusinessOutboxAssembly()
|
||||
{
|
||||
// Le DiagnosticOutbox vit dans Omika.Core ; l'outbox métier vit dans
|
||||
// Omika.App.Local. Core ne référence pas App.Local (jamais l'inverse).
|
||||
Assembly core = typeof(DiagnosticOutbox).Assembly;
|
||||
Assert.DoesNotContain(core.GetReferencedAssemblies(), a => a.Name == "Omika.App.Local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticOutboxStoreInterface_IsProviderAgnostic()
|
||||
{
|
||||
// Les signatures du store ne font référence à aucun type de l'outbox métier
|
||||
// (IOutboxRepository / OutboxOperation) ni à SQLite.
|
||||
foreach (MethodInfo method in typeof(IDiagnosticOutboxStore).GetMethods())
|
||||
{
|
||||
Assert.DoesNotContain("Business", method.Name, StringComparison.Ordinal);
|
||||
foreach (Type type in method.ReturnType.GetGenericArguments()
|
||||
.Concat(method.GetParameters().SelectMany(p => ParameterTypes(p.ParameterType))))
|
||||
{
|
||||
Assert.DoesNotContain("OutboxRepository", type.FullName ?? "", StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Microsoft.Data.Sqlite", type.FullName ?? "", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Core_DoesNotReferenceExternalObservabilityInfrastructure()
|
||||
{
|
||||
Assembly core = typeof(OmikaEvent).Assembly;
|
||||
|
||||
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name!).ToArray();
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("OpenTelemetry"));
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("ClickHouse"));
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("SigNoz"));
|
||||
|
||||
// Aucun type OTLP / OpenTelemetry dans l'assemblage Core.
|
||||
Assert.DoesNotContain(
|
||||
core.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> ParameterTypes(Type type)
|
||||
{
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
foreach (Type argument in type.GetGenericArguments())
|
||||
{
|
||||
foreach (Type inner in ParameterTypes(argument))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du backoff borné avec jitter (§43).</summary>
|
||||
public class DiagnosticBackoffPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void NextDelay_IsBoundedByMax()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(15),
|
||||
BackoffMax = TimeSpan.FromHours(1)
|
||||
};
|
||||
var policy = new DiagnosticBackoffPolicy(options);
|
||||
|
||||
// Garantie absolue : 0 < délai <= MaxDelay, pour tout attempt, y compris les
|
||||
// valeurs extrêmes. Le jitter (aléatoire) ne peut JAMAIS faire dépasser MaxDelay
|
||||
// car la borne finale est appliquée après le jitter.
|
||||
int[] attempts = [0, 1, 5, 9, 20, 50, 100, int.MaxValue];
|
||||
foreach (int attempt in attempts)
|
||||
{
|
||||
TimeSpan delay = policy.NextDelay(attempt);
|
||||
Assert.True(delay > TimeSpan.Zero, $"attempt {attempt}: délai non strictement positif ({delay})");
|
||||
Assert.True(delay <= options.BackoffMax, $"attempt {attempt}: délai {delay} dépasse le maximum {options.BackoffMax}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextDelay_GrowsWithAttempts()
|
||||
{
|
||||
var policy = new DiagnosticBackoffPolicy(new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(15),
|
||||
BackoffMax = TimeSpan.FromHours(1),
|
||||
BackoffJitter = 0.0
|
||||
});
|
||||
|
||||
TimeSpan first = policy.NextDelay(1);
|
||||
TimeSpan second = policy.NextDelay(2);
|
||||
TimeSpan third = policy.NextDelay(3);
|
||||
|
||||
Assert.True(second >= first);
|
||||
Assert.True(third >= second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextDelay_WithJitter_StaysWithinJitterBounds()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(10),
|
||||
BackoffMax = TimeSpan.FromMinutes(1),
|
||||
BackoffJitter = 0.2
|
||||
};
|
||||
var policy = new DiagnosticBackoffPolicy(options);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
TimeSpan delay = policy.NextDelay(1);
|
||||
Assert.InRange(delay.TotalMilliseconds, 8000, 12000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la validation stricte serveur (§32, §28, §29, §60).</summary>
|
||||
public class DiagnosticBatchValidatorTests
|
||||
{
|
||||
private static readonly RemoteDiagnosticOptions Limits = new();
|
||||
|
||||
private static DiagnosticBatchDto NewBatch(int eventCount = 1) =>
|
||||
new()
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Events = Enumerable.Range(0, eventCount)
|
||||
.Select(i => new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3"
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ValidBatch_Passes()
|
||||
{
|
||||
DiagnosticValidationResult result = DiagnosticBatchValidator.Validate(NewBatch(), Limits);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullBatch_Rejected()
|
||||
{
|
||||
Assert.False(DiagnosticBatchValidator.Validate(null, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongSchemaVersion_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch = new DiagnosticBatchDto { SchemaVersion = 999, Events = batch.Events };
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyBatch_Rejected()
|
||||
{
|
||||
Assert.False(DiagnosticBatchValidator.Validate(new DiagnosticBatchDto(), Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooManyEvents_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch(Limits.MaxEventsPerBatch + 1);
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyEventId_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto { EventCode = "OMK-MOB-APP-001" };
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetadataKey_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["password"] = "secret" }
|
||||
};
|
||||
|
||||
DiagnosticValidationResult result = DiagnosticBatchValidator.Validate(batch, Limits);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(DiagnosticValidationReason.ForbiddenMetadata, result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetadataValue_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["error"] = "ConnectionString=secret" }
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooManyMetadata_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
var metadata = new Dictionary<string, object?>();
|
||||
for (int i = 0; i <= Limits.MaxMetadataCount; i++)
|
||||
{
|
||||
metadata[$"k{i}"] = i;
|
||||
}
|
||||
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongString_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = new string('x', Limits.MaxStringLength + 1)
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComplexMetadataValue_Rejected()
|
||||
{
|
||||
// Une valeur de métadonnée objet/tableau (non simple) est refusée.
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["nested"] = new { a = 1 } }
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du mapping événement → DTO transport (§27, §28, §29, §63).</summary>
|
||||
public class DiagnosticEventMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToDto_CopiesWhitelistedFields_AndPreservesEventId()
|
||||
{
|
||||
Guid eventId = Guid.NewGuid();
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
Guid operationId = Guid.NewGuid();
|
||||
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Level = TelemetryLevel.Error,
|
||||
Priority = TelemetryPriority.P1,
|
||||
OperationId = operationId,
|
||||
Component = "SyncEngine",
|
||||
AppVersion = "1.2.3",
|
||||
DeviceId = "device-1"
|
||||
};
|
||||
e.TraceId = traceId;
|
||||
e.SessionId = sessionId;
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(eventId, dto!.EventId);
|
||||
Assert.Equal(traceId, dto.TraceId);
|
||||
Assert.Equal(sessionId, dto.SessionId);
|
||||
Assert.Equal(operationId, dto.OperationId);
|
||||
Assert.Equal("device-1", dto.DeviceId);
|
||||
Assert.Equal("1.2.3", dto.AppVersion);
|
||||
Assert.Equal("SyncEngine", dto.Component);
|
||||
Assert.Equal("P1", dto.Priority);
|
||||
Assert.Equal("Error", dto.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesDeviceDimensions_AppBuildAndScreen()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
AppBuild = "1410",
|
||||
ScreenWidth = 1080,
|
||||
ScreenHeight = 2400,
|
||||
LogicalWidth = 360,
|
||||
LogicalHeight = 800,
|
||||
DisplayScale = 3.0
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("1410", dto!.AppBuild);
|
||||
Assert.Equal(1080, dto.ScreenWidth);
|
||||
Assert.Equal(2400, dto.ScreenHeight);
|
||||
Assert.Equal(360, dto.LogicalWidth);
|
||||
Assert.Equal(800, dto.LogicalHeight);
|
||||
Assert.Equal(3.0, dto.DisplayScale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesScreenCodeAndFeatureCode()
|
||||
{
|
||||
// T18 : le mapping transport doit conserver ScreenCode/FeatureCode (dimensions
|
||||
// déclaratives d'usage) — c'est le contrat vérifié de bout en bout.
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileScreenView,
|
||||
ScreenCode = DiagnosticScreenCodes.SiteDetail,
|
||||
FeatureCode = DiagnosticFeatureCodes.ReportGenerate
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, dto!.ScreenCode);
|
||||
Assert.Equal(DiagnosticFeatureCodes.ReportGenerate, dto.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesScreenCodeAndFeatureCode()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileScreenView,
|
||||
ScreenCode = DiagnosticScreenCodes.RendezVousDetail,
|
||||
FeatureCode = DiagnosticFeatureCodes.InterventionSearchRun
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
string json = DiagnosticEventMapper.Serialize(dto!);
|
||||
DiagnosticEventDto? back = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.Equal(DiagnosticScreenCodes.RendezVousDetail, back!.ScreenCode);
|
||||
Assert.Equal(DiagnosticFeatureCodes.InterventionSearchRun, back.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_NullScreenCodeAndFeatureCode_StayNull()
|
||||
{
|
||||
// Compatibilité : les événements historiques/background sans usage fonctionnel
|
||||
// restent ScreenCode=null / FeatureCode=null (aucune invention).
|
||||
var e = new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted };
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Null(dto!.ScreenCode);
|
||||
Assert.Null(dto.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_InvalidEventCode_ReturnsNull()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(code: EventCode.Invalid);
|
||||
|
||||
Assert.Null(DiagnosticEventMapper.ToDto(e, 256));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_DropsForbiddenMetadataKeys()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(metadata: TelemetryMetadata.Create(
|
||||
("password", "secret"),
|
||||
("method", "POST"),
|
||||
("token", "abc")));
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.NotNull(dto!.Metadata);
|
||||
Assert.True(dto.Metadata!.ContainsKey("method"));
|
||||
Assert.False(dto.Metadata.ContainsKey("password"));
|
||||
Assert.False(dto.Metadata.ContainsKey("token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_DropsForbiddenMetadataValues()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(metadata: TelemetryMetadata.Create(
|
||||
("error", "ConnectionString=Server=prod;Password=hidden"),
|
||||
("ok", "échec")));
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.NotNull(dto!.Metadata);
|
||||
Assert.False(dto.Metadata.ContainsKey("error"));
|
||||
Assert.True(dto.Metadata.ContainsKey("ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_TruncatesLongStrings()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Component = new string('x', 1000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(256, dto!.Component!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesUtf8Accents()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Component = "télémétrie",
|
||||
EntityType = "Créé"
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
string json = DiagnosticEventMapper.Serialize(dto!);
|
||||
DiagnosticEventDto? back = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.Equal("télémétrie", back!.Component);
|
||||
Assert.Equal("Créé", back.EntityType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_UnknownFields_AreDropped()
|
||||
{
|
||||
// Un champ "password" non prévu dans le DTO est ignoré par la désérialisation
|
||||
// typée (aucun JsonExtensionData) : il ne sera jamais persisté. (§60)
|
||||
string json = """
|
||||
{ "eventId": "00000000-0000-0000-0000-000000000001", "eventCode": "OMK-MOB-APP-001",
|
||||
"password": "secret", "component": "test" }
|
||||
""";
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("test", dto!.Component);
|
||||
Assert.Null(dto.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesExceptionDetail()
|
||||
{
|
||||
// Phase 6 : message/type/stack trace d'exception sont des champs first-class
|
||||
// du transport (jamais des métadonnées libres), préservés de bout en bout.
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileHandledException,
|
||||
Message = "Échec d'écriture",
|
||||
ExceptionType = "System.IO.IOException",
|
||||
StackTrace = "at Omika.App.Services.SyncEngine.Run()"
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("Échec d'écriture", dto!.Message);
|
||||
Assert.Equal("System.IO.IOException", dto.ExceptionType);
|
||||
Assert.Equal("at Omika.App.Services.SyncEngine.Run()", dto.StackTrace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_TruncatesExceptionDetail()
|
||||
{
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileUnhandledException,
|
||||
Message = new string('m', 500),
|
||||
ExceptionType = new string('t', 500),
|
||||
StackTrace = new string('s', 3000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(256, dto!.Message!.Length);
|
||||
Assert.Equal(256, dto.ExceptionType!.Length);
|
||||
Assert.Equal(DiagnosticEventMapper.MaxStackTraceLength, dto.StackTrace!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_SignaleLaTroncatureParUnMarqueur()
|
||||
{
|
||||
// Une coupe SILENCIEUSE se lit comme une valeur complète : sur un vrai ticket,
|
||||
// une pile coupée en plein identifiant laissait croire qu'elle s'arrêtait là.
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileUnhandledException,
|
||||
Message = new string('m', 500),
|
||||
StackTrace = new string('s', 3000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.EndsWith("…[tronqué]", dto!.Message, StringComparison.Ordinal);
|
||||
Assert.EndsWith("…[tronqué]", dto.StackTrace, StringComparison.Ordinal);
|
||||
// Le marqueur tient DANS la limite : la valeur transmise reste bornée.
|
||||
Assert.Equal(256, dto.Message!.Length);
|
||||
Assert.Equal(DiagnosticEventMapper.MaxStackTraceLength, dto.StackTrace!.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la liste noire inverse du transport diagnostic (§29).</summary>
|
||||
public class DiagnosticForbiddenDataTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("password")]
|
||||
[InlineData("Password")]
|
||||
[InlineData("PASSWORD")]
|
||||
[InlineData("connectionString")]
|
||||
[InlineData("requestBody")]
|
||||
[InlineData("smtpPassword")]
|
||||
[InlineData("advertisingId")]
|
||||
[InlineData("authorization")]
|
||||
public void IsForbiddenKey_DetectsForbiddenTokensCaseInsensitive(string key)
|
||||
{
|
||||
Assert.True(DiagnosticForbiddenData.IsForbiddenKey(key));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("method")]
|
||||
[InlineData("routeTemplate")]
|
||||
[InlineData("statusCode")]
|
||||
[InlineData("entityType")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void IsForbiddenKey_AllowsSafeKeys(string? key)
|
||||
{
|
||||
Assert.False(DiagnosticForbiddenData.IsForbiddenKey(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsForbiddenValue_DetectsSecretsInFreeText()
|
||||
{
|
||||
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("Authorization=Bearer abc"));
|
||||
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("server=prod;password=hidden"));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue("échec de connexion"));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue(""));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Omika.Core.Abstractions;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Core.Contracts.Mobile.License;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Runtime;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'intégration mobile → API (Phase 4) : authentification, validation stricte,
|
||||
/// persistance durable avant ACK, données interdites, anti-boucle, panne stockage.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class DiagnosticIngestionIntegrationTests : IDisposable
|
||||
{
|
||||
private const string SigningKey = "test-signing-key-for-integration-tests-only";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly string _localDir = CreateTempDirectory();
|
||||
private readonly string _remoteDir = CreateTempDirectory();
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public DiagnosticIngestionIntegrationTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
_factory = CreateFactory(_localDir, _remoteDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
ApplicationRuntimeContext.Reset();
|
||||
DeleteDirectory(_localDir);
|
||||
DeleteDirectory(_remoteDir);
|
||||
}
|
||||
|
||||
private string NewToken()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITokenService tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
|
||||
return tokenService.GenerateToken(new MobileTokenIdentity
|
||||
{
|
||||
CodeClient = "BATIK.01",
|
||||
DeviceId = "device-1",
|
||||
UserId = "user-1",
|
||||
CollaborateurId = Guid.NewGuid().ToString(),
|
||||
NumeroLicence = "lic-1"
|
||||
});
|
||||
}
|
||||
|
||||
private static DiagnosticBatchDto ValidBatch(string component = "test") =>
|
||||
new()
|
||||
{
|
||||
BatchId = Guid.NewGuid(),
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
Component = component
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutToken_ReturnsUnauthorized()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidBatch_IsPersisted_AndAcknowledged()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
DiagnosticBatchDto batch = ValidBatch("télémétrie");
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", batch);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
DiagnosticBatchResponseDto? body = await response.Content.ReadFromJsonAsync<DiagnosticBatchResponseDto>();
|
||||
Assert.Equal(batch.BatchId, body!.BatchId);
|
||||
Assert.Equal(1, body.AcceptedCount);
|
||||
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("\"eventId\"", StringComparison.Ordinal)));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(ReadFirstLine(_remoteDir));
|
||||
JsonElement evt = document.RootElement.GetProperty("event");
|
||||
Assert.Equal(batch.Events[0].EventId.ToString(), evt.GetProperty("eventId").GetString());
|
||||
Assert.Equal(batch.Events[0].TraceId!.Value.ToString(), evt.GetProperty("traceId").GetString());
|
||||
Assert.Equal(batch.Events[0].SessionId!.Value.ToString(), evt.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("télémétrie", evt.GetProperty("component").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForbiddenMetadata_ReturnsBadRequest_NothingPersisted()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
string json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "00000000-0000-0000-0000-000000000001",
|
||||
"events": [{
|
||||
"eventId": "00000000-0000-0000-0000-000000000002",
|
||||
"eventCode": "OMK-MOB-APP-001",
|
||||
"level": "Info",
|
||||
"priority": "P3",
|
||||
"metadata": { "password": "secret" }
|
||||
}]
|
||||
}
|
||||
""";
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
"/api/diagnostics",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(Directory.EnumerateFiles(_remoteDir, "*.jsonl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownField_IsNotPersisted()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
// Le champ "password" n'est pas dans le DTO : il est ignoré par la
|
||||
// désérialisation typée et ne doit jamais apparaître dans le fichier serveur.
|
||||
string json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "00000000-0000-0000-0000-000000000001",
|
||||
"password": "secret-password-value",
|
||||
"events": [{
|
||||
"eventId": "00000000-0000-0000-0000-000000000002",
|
||||
"eventCode": "OMK-MOB-APP-001",
|
||||
"level": "Info",
|
||||
"priority": "P3",
|
||||
"component": "ok"
|
||||
}]
|
||||
}
|
||||
""";
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
"/api/diagnostics",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("\"component\"", StringComparison.Ordinal)));
|
||||
|
||||
string content = string.Join("\n", Directory.EnumerateFiles(_remoteDir, "*.jsonl").Select(ReadAllTextShared));
|
||||
Assert.DoesNotContain("secret-password-value", content, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageFailure_Returns503_DoesNotCrash()
|
||||
{
|
||||
string blocker = Path.Combine(_localDir, "blocker");
|
||||
File.WriteAllText(blocker, "x");
|
||||
string inaccessible = Path.Combine(blocker, "sub");
|
||||
|
||||
using var factory = CreateFactory(_localDir, inaccessible);
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
string token;
|
||||
using (IServiceScope scope = factory.Services.CreateScope())
|
||||
{
|
||||
token = scope.ServiceProvider.GetRequiredService<ITokenService>()
|
||||
.GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "d", UserId = "u" });
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticsEndpoint_DoesNotEmitHttpEvents_OrLogBody()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch("MARKER-BODY-123"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Le fichier d'ingestion serveur contient la donnée (légitime).
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("MARKER-BODY-123", StringComparison.Ordinal)));
|
||||
|
||||
// Le journal local de l'API ne doit pas contenir le corps ni un événement
|
||||
// HTTP_REQUEST_* pour /api/diagnostics (anti-boucle, sans body).
|
||||
string local = string.Join("\n", Directory.EnumerateFiles(_localDir, "*.jsonl").Select(ReadAllTextShared));
|
||||
Assert.DoesNotContain("MARKER-BODY-123", local, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/api/diagnostics", local, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory(
|
||||
string localDir,
|
||||
string remoteDir,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Hfsql:Server"] = "test-server",
|
||||
["Hfsql:DatabaseName"] = "test-db",
|
||||
["MobileAuth:SigningKey"] = SigningKey
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
services.RemoveAll<IDatabaseRuntimeInitializer>();
|
||||
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
|
||||
|
||||
services.RemoveAll<IHfsqlConnection>();
|
||||
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
|
||||
|
||||
services.RemoveAll<ILicenseService>();
|
||||
services.AddScoped<ILicenseService>(_ => new FakeLicenseService());
|
||||
|
||||
services.RemoveAll<IMobileBootstrapService>();
|
||||
services.AddScoped<IMobileBootstrapService>(_ => new FakeBootstrapService());
|
||||
|
||||
services.Configure<LocalDiagnosticOptions>(options => options.Directory = localDir);
|
||||
services.Configure<RemoteDiagnosticOptions>(options => options.Directory = remoteDir);
|
||||
|
||||
configureServices?.Invoke(services);
|
||||
});
|
||||
});
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static string ReadFirstLine(string dir)
|
||||
{
|
||||
string file = Assert.Single(Directory.EnumerateFiles(dir, "*.jsonl"));
|
||||
string content = ReadAllTextShared(file);
|
||||
int newline = content.IndexOf('\n');
|
||||
return (newline >= 0 ? content[..newline] : content).TrimEnd('\r');
|
||||
}
|
||||
|
||||
private static string ReadAllTextShared(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-ingestion-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.
|
||||
}
|
||||
}
|
||||
|
||||
private 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.");
|
||||
}
|
||||
|
||||
// ===== Fakes =====
|
||||
|
||||
private sealed class FakeLicenseService : ILicenseService
|
||||
{
|
||||
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new LicenseResponse());
|
||||
|
||||
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new LicenseResponse());
|
||||
|
||||
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class FakeBootstrapService : IMobileBootstrapService
|
||||
{
|
||||
public Task<MobileBootstrapResponse> GetBootstrapAsync(MobileTokenIdentity identity, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new MobileBootstrapResponse());
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
|
||||
{
|
||||
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
return Task.FromResult(Result.Success());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
|
||||
public void Dispose() { }
|
||||
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task CloseAsync() => Task.CompletedTask;
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de l'exporteur outbox : never-throw, délégation, priorité.</summary>
|
||||
public class DiagnosticOutboxExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_DelegatesToOutbox()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var exporter = new DiagnosticOutboxExporter(outbox);
|
||||
|
||||
await exporter.ExportAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P1)], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, counters.OutboxStored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ThrowingOutbox_NeverThrows()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore { ThrowOnStore = true };
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var exporter = new DiagnosticOutboxExporter(outbox);
|
||||
|
||||
// Ne doit pas lever : l'exporteur avale l'erreur de l'outbox (fail-open).
|
||||
await exporter.ExportAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Omika.App.Local.Diagnostics;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du store SQLite séparé (omika-diagnostics.db) : persistance, rétention, éviction, redémarrage.</summary>
|
||||
public class DiagnosticOutboxSqliteStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-diag-sqlite-tests", Guid.NewGuid().ToString("N"));
|
||||
private string DbPath => Path.Combine(_dir, "omika-diagnostics.db");
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dir))
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort.
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DiagnosticOutboxRow NewRow(TelemetryPriority priority = TelemetryPriority.P3, string payload = "{}") =>
|
||||
new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Priority = priority,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(7),
|
||||
PayloadVersion = 1,
|
||||
PayloadJson = payload,
|
||||
AttemptCount = 0,
|
||||
NextAttemptUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAndRetrieve_RoundTrip()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow row = NewRow(payload: """{"eventId":"00000000-0000-0000-0000-000000000001"}""");
|
||||
|
||||
await store.StoreAsync([row]);
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
|
||||
DiagnosticOutboxRow retrieved = Assert.Single(batch);
|
||||
Assert.Equal(row.EventId, retrieved.EventId);
|
||||
Assert.Equal(row.PayloadJson, retrieved.PayloadJson);
|
||||
Assert.Equal(row.Priority, retrieved.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextBatch_RespectsNextAttemptUtc()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow future = NewRow();
|
||||
future = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = future.EventId,
|
||||
Priority = future.Priority,
|
||||
CreatedAtUtc = future.CreatedAtUtc,
|
||||
ExpiresAtUtc = future.ExpiresAtUtc,
|
||||
PayloadVersion = future.PayloadVersion,
|
||||
PayloadJson = future.PayloadJson,
|
||||
NextAttemptUtc = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
await store.StoreAsync([future]);
|
||||
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
Assert.Empty(batch);
|
||||
|
||||
IReadOnlyList<DiagnosticOutboxRow> later = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow.AddMinutes(10));
|
||||
Assert.Single(later);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteExpired_RemovesOnlyExpired()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow expired = NewRow();
|
||||
expired = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = expired.EventId,
|
||||
Priority = expired.Priority,
|
||||
CreatedAtUtc = expired.CreatedAtUtc,
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
PayloadVersion = expired.PayloadVersion,
|
||||
PayloadJson = expired.PayloadJson
|
||||
};
|
||||
await store.StoreAsync([expired, NewRow()]);
|
||||
|
||||
int removed = await store.DeleteExpiredAsync(DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOldest_RemovesOldestOfPriority()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow old = NewRow(TelemetryPriority.P3);
|
||||
await store.StoreAsync([old]);
|
||||
await Task.Delay(10);
|
||||
DiagnosticOutboxRow newer = NewRow(TelemetryPriority.P3);
|
||||
await store.StoreAsync([newer]);
|
||||
|
||||
int removed = await store.DeleteOldestAsync(TelemetryPriority.P3, 1);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.False((await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow)).Any(r => r.EventId == old.EventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_PersistsRowsAcrossSessions()
|
||||
{
|
||||
DiagnosticOutboxRow row = NewRow(payload: """{"eventId":"00000000-0000-0000-0000-00000000000A"}""");
|
||||
await using (var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath }))
|
||||
{
|
||||
await store.StoreAsync([row]);
|
||||
}
|
||||
|
||||
// « Redémarrage » : nouvelle connexion sur le même fichier.
|
||||
await using (var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath }))
|
||||
{
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
Assert.Equal(row.EventId, Assert.Single(batch).EventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du DiagnosticOutbox : filtrage P0..P3, saturation, éviction, rétention, fail-open.</summary>
|
||||
public class DiagnosticOutboxTests
|
||||
{
|
||||
private static (DiagnosticOutbox Outbox, InMemoryDiagnosticOutboxStore Store, DiagnosticCounters Counters) Create(
|
||||
DiagnosticOutboxOptions? options = null)
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, options ?? new DiagnosticOutboxOptions(), counters);
|
||||
return (outbox, store, counters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_StoresP0ThroughP3_ByDefault()
|
||||
{
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create();
|
||||
|
||||
int stored = await outbox.StoreAsync(
|
||||
[
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P0),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P1),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P2),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P3)
|
||||
]);
|
||||
|
||||
Assert.Equal(4, stored);
|
||||
Assert.Equal(4, counters.OutboxStored);
|
||||
Assert.Equal(0, counters.OutboxDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_DropsP4_ByDefault()
|
||||
{
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create();
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P4)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(1, counters.OutboxDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_IncludeP4Option_StoresP4()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { IncludeP4 = true };
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P4)]);
|
||||
|
||||
Assert.Equal(1, stored);
|
||||
Assert.Equal(1, counters.OutboxStored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_EvictsP3_BeforeP0_WhenSaturated()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { MaxRows = 10 };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
// Remplit l'outbox avec 10 P3.
|
||||
List<OmikaEvent> p3s = Enumerable.Range(0, 10)
|
||||
.Select(_ => (OmikaEvent)OutboxTestHarness.NewEvent(TelemetryPriority.P3))
|
||||
.ToList();
|
||||
await outbox.StoreAsync(p3s);
|
||||
Assert.Equal(10, await store.CountAsync());
|
||||
|
||||
// Un nouveau P0 doit évincer un P3 (pas un P0).
|
||||
await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(10, await store.CountAsync());
|
||||
Assert.Contains(store.Snapshot(), r => r.Priority == TelemetryPriority.P0);
|
||||
Assert.Equal(1, counters.OutboxEvictedP3);
|
||||
Assert.Equal(0, counters.OutboxEvictedP0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_FullOfP0_DropsNewP0_WithCounter()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { MaxRows = 5 };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
await outbox.StoreAsync(Enumerable.Range(0, 5)
|
||||
.Select(_ => OutboxTestHarness.NewEvent(TelemetryPriority.P0))
|
||||
.ToList());
|
||||
Assert.Equal(5, await store.CountAsync());
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(5, await store.CountAsync());
|
||||
Assert.Equal(1, counters.OutboxDroppedFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_Disabled_StoresNothing()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { Enabled = false };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, _) = Create(options);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpired_RemovesExpiredOnly()
|
||||
{
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create();
|
||||
|
||||
// Utilise une rétention courte pour forcer l'expiration.
|
||||
var options = new DiagnosticOutboxOptions { Retention = TimeSpan.FromMilliseconds(1) };
|
||||
(outbox, store, counters) = Create(options);
|
||||
await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P3)]);
|
||||
|
||||
await Task.Delay(20);
|
||||
int removed = await outbox.CleanupExpiredAsync();
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, counters.OutboxExpired);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_ThrowingStore_FailOpen_DegradesAndCounts()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore { ThrowOnStore = true };
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(1, counters.OutboxDegraded);
|
||||
Assert.True(outbox.IsDegraded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du contrat de transport versionné (§26, §27, §10).</summary>
|
||||
public class DiagnosticTransportTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_IsOne()
|
||||
{
|
||||
Assert.Equal(1, DiagnosticTransportSchema.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_SerializesWithSchemaVersionAndBatchId()
|
||||
{
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
BatchId = Guid.Parse("00000000-0000-0000-0000-0000000000AA"),
|
||||
SentAtUtc = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
Level = "Info",
|
||||
Priority = "P3"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(batch, JsonOptions);
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(
|
||||
"00000000-0000-0000-0000-0000000000aa",
|
||||
document.RootElement.GetProperty("batchId").GetString());
|
||||
Assert.Equal(1, document.RootElement.GetProperty("events").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_RoundTrips_BatchIdAndEventIdStable()
|
||||
{
|
||||
Guid batchId = Guid.NewGuid();
|
||||
Guid eventId = Guid.NewGuid();
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
BatchId = batchId,
|
||||
Events = [new DiagnosticEventDto { EventId = eventId, EventCode = "OMK-MOB-APP-001" }]
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(batch, JsonOptions);
|
||||
DiagnosticBatchDto? back = JsonSerializer.Deserialize<DiagnosticBatchDto>(json, JsonOptions);
|
||||
|
||||
Assert.Equal(batchId, back!.BatchId);
|
||||
Assert.Equal(eventId, back.Events[0].EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_RoundTrips()
|
||||
{
|
||||
var response = new DiagnosticBatchResponseDto { BatchId = Guid.NewGuid(), AcceptedCount = 3 };
|
||||
string json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
DiagnosticBatchResponseDto? back = JsonSerializer.Deserialize<DiagnosticBatchResponseDto>(json, JsonOptions);
|
||||
|
||||
Assert.Equal(response.BatchId, back!.BatchId);
|
||||
Assert.Equal(3, back.AcceptedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du client HTTP d'upload : classification des statuts, timeout, anti-boucle.</summary>
|
||||
public class DiagnosticUploadClientTests
|
||||
{
|
||||
private static DiagnosticBatchDto NewBatch() =>
|
||||
new()
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }
|
||||
]
|
||||
};
|
||||
|
||||
private static DiagnosticUploadClient CreateClient(
|
||||
StubHandler handler,
|
||||
DiagnosticUploadOptions? options = null,
|
||||
string? token = "token-1") =>
|
||||
new(
|
||||
new HttpClient(handler),
|
||||
"http://localhost:5000",
|
||||
() => token,
|
||||
options ?? new DiagnosticUploadOptions { Timeout = TimeSpan.FromSeconds(5) });
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_ReturnsUnauthorized_WithoutHttp()
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, token: null);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Unauthorized, result.Outcome);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_MapsToSuccess_WithAcceptedCount()
|
||||
{
|
||||
var handler = new StubHandler
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
ResponseBody = """{"batchId":"00000000-0000-0000-0000-000000000001","acceptedCount":3}"""
|
||||
};
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Success, result.Outcome);
|
||||
Assert.Equal(3, result.AcceptedCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized, DiagnosticUploadOutcome.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden, DiagnosticUploadOutcome.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.BadRequest, DiagnosticUploadOutcome.Rejected)]
|
||||
[InlineData(HttpStatusCode.RequestEntityTooLarge, DiagnosticUploadOutcome.TooLarge)]
|
||||
[InlineData(HttpStatusCode.InternalServerError, DiagnosticUploadOutcome.Transient)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, DiagnosticUploadOutcome.Transient)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout, DiagnosticUploadOutcome.Transient)]
|
||||
public async Task StatusCodes_MapToExpectedOutcome(HttpStatusCode status, DiagnosticUploadOutcome expected)
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = status };
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(expected, result.Outcome);
|
||||
Assert.Equal((int)status, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimited_HonorsRetryAfter()
|
||||
{
|
||||
var handler = new StubHandler
|
||||
{
|
||||
StatusCode = HttpStatusCode.TooManyRequests,
|
||||
RetryAfterSeconds = 42
|
||||
};
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.RateLimited, result.Outcome);
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), result.RetryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_MapsToTransient()
|
||||
{
|
||||
var handler = new StubHandler { DelayMs = 2000, StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, new DiagnosticUploadOptions { Timeout = TimeSpan.FromMilliseconds(10) });
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Transient, result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_SendsBearerToken()
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, token: "secret-token");
|
||||
|
||||
await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal("Bearer secret-token", handler.LastAuthorization);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
|
||||
public int DelayMs { get; set; }
|
||||
|
||||
public string ResponseBody { get; set; } = "{}";
|
||||
|
||||
public int? RetryAfterSeconds { get; set; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public string? LastAuthorization { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
LastAuthorization = request.Headers.Authorization?.ToString();
|
||||
if (DelayMs > 0)
|
||||
{
|
||||
await Task.Delay(DelayMs, cancellationToken);
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(StatusCode);
|
||||
if (RetryAfterSeconds is { } seconds)
|
||||
{
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(seconds));
|
||||
}
|
||||
|
||||
response.Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du DiagnosticUploadClient par fournisseur d'URL : l'URL est résolue à
|
||||
/// chaque envoi depuis la même source de vérité que le client métier.
|
||||
/// </summary>
|
||||
public class DiagnosticUploadProviderTests
|
||||
{
|
||||
private sealed class UriCapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public string? LastUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastUri = request.RequestUri?.ToString();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"acceptedCount":1}""", Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_UtiliseUrlDuFournisseur()
|
||||
{
|
||||
var handler = new UriCapturingHandler();
|
||||
string? currentBaseUrl = "https://api.example.com";
|
||||
|
||||
var client = new DiagnosticUploadClient(
|
||||
new System.Net.Http.HttpClient(handler),
|
||||
() => currentBaseUrl,
|
||||
() => "token-1",
|
||||
new DiagnosticUploadOptions());
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(new DiagnosticBatchDto
|
||||
{
|
||||
Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }]
|
||||
});
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Success, result.Outcome);
|
||||
Assert.Equal("https://api.example.com/api/diagnostics", handler.LastUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_SansUrl_AucunAppelHttp()
|
||||
{
|
||||
var handler = new UriCapturingHandler();
|
||||
|
||||
var client = new DiagnosticUploadClient(
|
||||
new System.Net.Http.HttpClient(handler),
|
||||
() => null,
|
||||
() => "token-1",
|
||||
new DiagnosticUploadOptions());
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(new DiagnosticBatchDto
|
||||
{
|
||||
Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }]
|
||||
});
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Failure, result.Outcome);
|
||||
Assert.Null(handler.LastUri); // aucun fallback localhost, aucun envoi.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du worker d'upload : succès/suppression, retry, auth, poison, split, anti-loop.</summary>
|
||||
public class DiagnosticUploadWorkerTests
|
||||
{
|
||||
private static (DiagnosticUploadWorker Worker, InMemoryDiagnosticOutboxStore Store, DiagnosticCounters Counters, FakeUploadClient Client) Create(
|
||||
DiagnosticUploadOptions? uploadOptions = null,
|
||||
string? token = "token-1",
|
||||
Func<DiagnosticBatchDto, DiagnosticUploadResult>? handler = null)
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var client = new FakeUploadClient { Handler = handler ?? (_ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Success)) };
|
||||
var worker = new DiagnosticUploadWorker(
|
||||
outbox,
|
||||
client,
|
||||
uploadOptions ?? new DiagnosticUploadOptions(),
|
||||
counters,
|
||||
() => token);
|
||||
return (worker, store, counters, client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_DeletesRows_AndCounts()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) = Create();
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
bool attempted = await worker.FlushAsync();
|
||||
|
||||
Assert.True(attempted);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadSuccess);
|
||||
Assert.Equal(1, counters.UploadEventsSent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransientFailure_ConservesRows_IncrementsAttempt()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Transient));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadFailure);
|
||||
DiagnosticOutboxRow row = store.Snapshot().Single();
|
||||
Assert.Equal(1, row.AttemptCount);
|
||||
Assert.True(row.NextAttemptUtc > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthorized_ConservesRows_NoDelete()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Unauthorized));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadUnauthorized);
|
||||
Assert.Equal(0, counters.UploadSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedSingleton_IsDroppedAsPoison()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Rejected));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadPoisonDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedLargeBatch_Splits_UntilSingletonsSucceed()
|
||||
{
|
||||
// Le serveur rejette tout lot de plus d'un événement (400) : le worker divise
|
||||
// jusqu'aux singletons, qui sont acceptés. Aucun poison, tout est envoyé.
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: batch => batch.Events.Count > 1
|
||||
? new DiagnosticUploadResult(DiagnosticUploadOutcome.Rejected)
|
||||
: new DiagnosticUploadResult(DiagnosticUploadOutcome.Success));
|
||||
await SeedAsync(store, 4);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(4, counters.UploadEventsSent);
|
||||
Assert.Equal(0, counters.UploadPoisonDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_DoesNotUpload_ConservesRows()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, _, FakeUploadClient client) = Create(token: null);
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(0, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_LoopDoesNotUpload()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions { Enabled = false, Interval = TimeSpan.FromMilliseconds(10) };
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, _, FakeUploadClient client) = Create(uploadOptions: options);
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
worker.Start();
|
||||
await Task.Delay(50);
|
||||
await worker.StopAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(0, client.Calls);
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(InMemoryDiagnosticOutboxStore store, int count)
|
||||
{
|
||||
// Insère des événements mappés dans le store pour isoler le comportement d'upload.
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), new DiagnosticCounters());
|
||||
await outbox.StoreAsync(Enumerable.Range(0, count)
|
||||
.Select(_ => OutboxTestHarness.NewEvent(TelemetryPriority.P3))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private sealed class FakeUploadClient : IDiagnosticUploadClient
|
||||
{
|
||||
public Func<DiagnosticBatchDto, DiagnosticUploadResult>? Handler { get; set; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<DiagnosticUploadResult> UploadAsync(DiagnosticBatchDto batch, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(Handler!(batch));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Text;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Fabrique de test partagée pour le diagnostic distant (Phase 4).</summary>
|
||||
internal static class OutboxTestHarness
|
||||
{
|
||||
public static TelemetryEvent NewEvent(
|
||||
TelemetryPriority priority = TelemetryPriority.P3,
|
||||
TelemetryLevel level = TelemetryLevel.Info,
|
||||
EventCode? code = null,
|
||||
Guid? eventId = null,
|
||||
TelemetryMetadata? metadata = null) =>
|
||||
new()
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
EventCode = code ?? TelemetryEventCodes.MobileAppStarted,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lit un fichier en cohabitation avec un writer encore actif. Les exporteurs
|
||||
/// (LocalFileTelemetryExporter / RemoteDiagnosticIngestionStore) ouvrent en
|
||||
/// <c>FileAccess.Write, FileShare.Read</c> (un writer, lectures autorisées) : un
|
||||
/// lecteur doit donc ouvrir avec <c>FileShare.ReadWrite</c> — jamais
|
||||
/// <see cref="File.ReadAllText"/> / <see cref="File.ReadAllLines"/> dont le
|
||||
/// <c>FileShare.Read</c> rejette l'écriture concurrente.
|
||||
/// </summary>
|
||||
public static string ReadAllTextShared(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
/// <summary>Équivalent lignes de <see cref="ReadAllTextShared"/> (UTF-8, partage d'écriture).</summary>
|
||||
public static string[] ReadAllLinesShared(string path)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store en mémoire pour les tests Core (aucune dépendance SQLite). Permet
|
||||
/// d'injecter une panne (ThrowOn) pour vérifier le fail-open de l'outbox.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryDiagnosticOutboxStore : IDiagnosticOutboxStore
|
||||
{
|
||||
private readonly List<DiagnosticOutboxRow> _rows = [];
|
||||
|
||||
public bool ThrowOnStore { get; set; }
|
||||
|
||||
public bool ThrowOnCount { get; set; }
|
||||
|
||||
public Task StoreAsync(IReadOnlyList<DiagnosticOutboxRow> rows, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnStore)
|
||||
{
|
||||
throw new InvalidOperationException("store down");
|
||||
}
|
||||
|
||||
lock (_rows)
|
||||
{
|
||||
foreach (DiagnosticOutboxRow row in rows)
|
||||
{
|
||||
_rows.RemoveAll(r => r.EventId == row.EventId);
|
||||
_rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticOutboxRow>> GetNextBatchAsync(
|
||||
int maxCount,
|
||||
long maxBytes,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
var result = new List<DiagnosticOutboxRow>();
|
||||
long bytes = 0;
|
||||
foreach (DiagnosticOutboxRow row in _rows
|
||||
.Where(r => r.NextAttemptUtc <= now)
|
||||
.OrderBy(r => r.Priority)
|
||||
.ThenBy(r => r.CreatedAtUtc))
|
||||
{
|
||||
long rowBytes = Encoding.UTF8.GetByteCount(row.PayloadJson);
|
||||
if (result.Count > 0 && bytes + rowBytes > maxBytes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
result.Add(row);
|
||||
bytes += rowBytes;
|
||||
if (result.Count >= maxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DiagnosticOutboxRow>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(IReadOnlyList<Guid> eventIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
HashSet<Guid> ids = eventIds.ToHashSet();
|
||||
_rows.RemoveAll(r => ids.Contains(r.EventId));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAttemptAsync(
|
||||
IReadOnlyList<Guid> eventIds,
|
||||
int attemptCount,
|
||||
DateTimeOffset nextAttemptUtc,
|
||||
string? lastErrorCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
HashSet<Guid> ids = eventIds.ToHashSet();
|
||||
foreach (DiagnosticOutboxRow row in _rows.Where(r => ids.Contains(r.EventId)).ToList())
|
||||
{
|
||||
int index = _rows.IndexOf(row);
|
||||
_rows[index] = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = row.EventId,
|
||||
Priority = row.Priority,
|
||||
CreatedAtUtc = row.CreatedAtUtc,
|
||||
ExpiresAtUtc = row.ExpiresAtUtc,
|
||||
PayloadVersion = row.PayloadVersion,
|
||||
PayloadJson = row.PayloadJson,
|
||||
AttemptCount = attemptCount,
|
||||
NextAttemptUtc = nextAttemptUtc,
|
||||
LastAttemptUtc = DateTimeOffset.UtcNow,
|
||||
LastErrorCode = lastErrorCode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnCount)
|
||||
{
|
||||
throw new InvalidOperationException("count down");
|
||||
}
|
||||
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult(_rows.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult(_rows.RemoveAll(r => r.ExpiresAtUtc <= now));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> DeleteOldestAsync(TelemetryPriority priority, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
List<Guid> toDelete = _rows
|
||||
.Where(r => r.Priority == priority)
|
||||
.OrderBy(r => r.CreatedAtUtc)
|
||||
.Take(count)
|
||||
.Select(r => r.EventId)
|
||||
.ToList();
|
||||
|
||||
_rows.RemoveAll(r => toDelete.Contains(r.EventId));
|
||||
return Task.FromResult(toDelete.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<long> GetDatabaseSizeBytesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult((long)_rows.Count * 100);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<DiagnosticOutboxRow> Snapshot()
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return _rows.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class PriorityTelemetryQueueTests
|
||||
{
|
||||
private static (PriorityTelemetryQueue Queue, TelemetryCounters Counters) CreateQueue(
|
||||
int p0 = 2, int p1 = 2, int p2 = 2, int p3 = 2, int p4 = 2)
|
||||
{
|
||||
var options = new ObservabilityOptions
|
||||
{
|
||||
P0QueueCapacity = p0,
|
||||
P1QueueCapacity = p1,
|
||||
P2QueueCapacity = p2,
|
||||
P3QueueCapacity = p3,
|
||||
P4QueueCapacity = p4
|
||||
};
|
||||
var counters = new TelemetryCounters();
|
||||
return (new PriorityTelemetryQueue(options, counters), counters);
|
||||
}
|
||||
|
||||
private static TelemetryEvent Event(TelemetryPriority priority) =>
|
||||
new() { EventCode = TelemetryEventCodes.MobileAppStarted, Priority = priority };
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_ThenCount_Increments()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P3));
|
||||
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachPriority_UsesItsOwnQueue()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P0));
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P1));
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P2));
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P3));
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P4));
|
||||
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P0));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P1));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P2));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P3));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P4Full_NewP3_IsAccepted()
|
||||
{
|
||||
var (queue, counters) = CreateQueue(p4: 1);
|
||||
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P4)));
|
||||
// File P4 pleine : le P3 part dans sa propre file, indépendante.
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P3)));
|
||||
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P4));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P3));
|
||||
Assert.Equal(0L, counters.DroppedQueueFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P1Full_NewP0_IsAccepted_P1Unchanged()
|
||||
{
|
||||
var (queue, counters) = CreateQueue(p1: 2, p0: 2);
|
||||
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P1)));
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P1)));
|
||||
// File P1 saturée : le P0 est accepté dans sa propre file, P1 inchangé.
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P0)));
|
||||
|
||||
Assert.Equal(2, queue.GetQueueCount(TelemetryPriorityQueues.P1));
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P0));
|
||||
Assert.Equal(0L, counters.DroppedQueueFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P0Full_NewP0_IsDropped_Immediately_WithCounter()
|
||||
{
|
||||
var (queue, counters) = CreateQueue(p0: 1);
|
||||
|
||||
Assert.True(queue.TryEnqueue(Event(TelemetryPriority.P0)));
|
||||
// Retourne immédiatement (aucun blocage), événement abandonné, compteur P0 incrémenté.
|
||||
Assert.False(queue.TryEnqueue(Event(TelemetryPriority.P0)));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedQueueFull);
|
||||
Assert.Equal(1L, counters.DroppedP0);
|
||||
Assert.Equal(1L, counters.GetPriorityDropCount(TelemetryPriorityQueues.P0));
|
||||
Assert.Equal(0L, counters.DroppedP1);
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_DrainsInPriorityOrder()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P0));
|
||||
queue.TryEnqueue(Event(TelemetryPriority.P4));
|
||||
|
||||
Assert.True(queue.TryRead(TelemetryPriorityQueues.P0, out OmikaEvent critical));
|
||||
Assert.True(queue.TryRead(TelemetryPriorityQueues.P4, out OmikaEvent analytics));
|
||||
|
||||
Assert.Equal(TelemetryPriority.P0, critical.EffectivePriority);
|
||||
Assert.Equal(TelemetryPriority.P4, analytics.EffectivePriority);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidCapacity_DoesNotThrow_AndIsClamped()
|
||||
{
|
||||
var (queue, _) = CreateQueue(p0: -5, p1: 0);
|
||||
|
||||
Assert.Equal(1, queue.CapacityP0);
|
||||
Assert.Equal(1, queue.CapacityP1);
|
||||
Assert.Equal(1 + 1 + 2 + 2 + 2, queue.TotalCapacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalCapacity_IsSumOfFiveQueues()
|
||||
{
|
||||
var (queue, _) = CreateQueue(p0: 1, p1: 2, p2: 3, p3: 4, p4: 5);
|
||||
|
||||
Assert.Equal(15, queue.TotalCapacity);
|
||||
Assert.Equal(1, queue.CapacityP0);
|
||||
Assert.Equal(2, queue.CapacityP1);
|
||||
Assert.Equal(3, queue.CapacityP2);
|
||||
Assert.Equal(4, queue.CapacityP3);
|
||||
Assert.Equal(5, queue.CapacityP4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class RateLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public void Storm_IdenticalEvents_IsRateLimited_AndBounded()
|
||||
{
|
||||
// NOTE : RateLimitPerEvent est volontairement abaissé à 100 (défaut = 1000)
|
||||
// pour rendre le test de tempête rapide et lisible. La fenêtre fixe est
|
||||
// de 1 seconde ; 100 000 occurrences mono-thread → ~100 acceptées, le
|
||||
// reste abandonné par la protection anti-log-storm.
|
||||
var options = new ObservabilityOptions { RateLimitPerEvent = 100 };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
const long total = 100_000;
|
||||
for (long i = 0; i < total; i++)
|
||||
{
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
}
|
||||
|
||||
// Tous les événements sont comptabilisés une et une seule fois.
|
||||
Assert.Equal(total, counters.Accepted + counters.DroppedTotal);
|
||||
// La protection rate limit a bien abandonné des occurrences.
|
||||
Assert.True(counters.DroppedRateLimited > 0);
|
||||
// La mémoire est bornée : on n'accepte qu'une fraction du flux.
|
||||
Assert.True(counters.Accepted <= total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactRateLimit_SingleThreaded_1000Events_RateLimit100()
|
||||
{
|
||||
// Fenêtre fixe = 1 s, exécution séquentielle mono-thread (aucune course).
|
||||
// Le limiter accepte exactement les 100 premières occurrences puis droppe
|
||||
// les 900 suivantes : le résultat est déterministe dans ce cadre.
|
||||
var options = new ObservabilityOptions { RateLimitPerEvent = 100 };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
}
|
||||
|
||||
Assert.Equal(100L, counters.Accepted);
|
||||
Assert.Equal(900L, counters.DroppedRateLimited);
|
||||
Assert.Equal(1000L, counters.Accepted + counters.DroppedTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyDistinctCodes_AreBounded_NoThrow()
|
||||
{
|
||||
var options = new ObservabilityOptions();
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
// Plus de codes distincts que la capacité interne de suivi (1024) : les
|
||||
// codes excédentaires sont abandonnés sans exception, la mémoire reste bornée.
|
||||
for (int i = 0; i < 5000; i++)
|
||||
{
|
||||
EventCode code = EventCode.Create("MOB", "T" + i.ToString("D7"), 1);
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(code));
|
||||
}
|
||||
|
||||
Assert.Equal(5000L, counters.Accepted + counters.DroppedTotal);
|
||||
Assert.True(counters.DroppedRateLimited > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class SafeTelemetrySinkFailOpenTests
|
||||
{
|
||||
[Fact]
|
||||
public void ThrowingQueue_DoesNotPropagate_AndCountsInternalDrop()
|
||||
{
|
||||
var options = new ObservabilityOptions();
|
||||
var counters = new TelemetryCounters();
|
||||
var sink = new SafeTelemetrySink(options, new ThrowingQueue(), counters);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedInternal);
|
||||
Assert.Equal(0L, counters.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingQueue_Log_DoesNotPropagate()
|
||||
{
|
||||
var sink = new SafeTelemetrySink(
|
||||
new ObservabilityOptions(), new ThrowingQueue(), new TelemetryCounters());
|
||||
|
||||
sink.Log(ObservabilityTestHarness.NewDiagnostic(TelemetryEventCodes.ApiRequestFailed));
|
||||
// Aucune exception : l'assertion porte sur l'absence de levée.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownMode_IsTreatedAsOff_NoThrow()
|
||||
{
|
||||
var options = new ObservabilityOptions { Mode = (TelemetryMode)999 };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedDisabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfRangePriority_IsDowngraded_NoThrow()
|
||||
{
|
||||
var (sink, queue, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(
|
||||
TelemetryEventCodes.MobileAppStarted, priority: (TelemetryPriority)999));
|
||||
|
||||
// Rétrogradée vers la file P4 (la moins critique).
|
||||
Assert.Equal(1, queue.GetQueueCount(TelemetryPriorityQueues.P4));
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
}
|
||||
|
||||
private sealed class ThrowingQueue : ITelemetryQueue
|
||||
{
|
||||
public bool TryEnqueue(OmikaEvent @event) => throw new InvalidOperationException("queue défaillante (test)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class SafeTelemetrySinkTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidEvent_IsAccepted()
|
||||
{
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
Assert.Equal(0L, counters.DroppedTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullEvent_IsDropped_WithoutThrow()
|
||||
{
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
sink.Track(null!);
|
||||
|
||||
Assert.Equal(1L, counters.DroppedNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_IsDropped()
|
||||
{
|
||||
var options = new ObservabilityOptions { Enabled = false };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedDisabled);
|
||||
Assert.Equal(0L, counters.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeOff_IsDropped()
|
||||
{
|
||||
var options = new ObservabilityOptions { Mode = TelemetryMode.Off };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedDisabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelFiltered_IsDropped()
|
||||
{
|
||||
var options = new ObservabilityOptions { Mode = TelemetryMode.ErrorsOnly };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted, level: TelemetryLevel.Info));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidCode_IsDropped()
|
||||
{
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
sink.Track(new TelemetryEvent { EventCode = default });
|
||||
|
||||
Assert.Equal(1L, counters.DroppedInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetadataTooLarge_IsDropped()
|
||||
{
|
||||
var options = new ObservabilityOptions { MaxMetadataProperties = 2 };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
var metadata = TelemetryMetadata.Create(("a", 1), ("b", 2), ("c", 3));
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(
|
||||
TelemetryEventCodes.MobileAppStarted, metadata: metadata));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringTooLong_IsDropped()
|
||||
{
|
||||
var options = new ObservabilityOptions { MaxStringLength = 4 };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
var metadata = TelemetryMetadata.Create(("k", new string('x', 10)));
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(
|
||||
TelemetryEventCodes.MobileAppStarted, metadata: metadata));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueFull_IsDropped_WithQueueFullCounter()
|
||||
{
|
||||
var options = new ObservabilityOptions
|
||||
{
|
||||
P0QueueCapacity = 1,
|
||||
P1QueueCapacity = 1,
|
||||
P2QueueCapacity = 1,
|
||||
P3QueueCapacity = 1,
|
||||
P4QueueCapacity = 1
|
||||
};
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
Assert.Equal(1L, counters.DroppedQueueFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounting_Invariant_EveryEventIsCountedOnce()
|
||||
{
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
}
|
||||
|
||||
Assert.Equal(100L, counters.Accepted + counters.DroppedTotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Omika.App.Diagnostics;
|
||||
using Omika.App.Navigation;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// T18 (§73/§74/§75/§77) : une navigation logique génère EXACTEMENT un ScreenView ;
|
||||
/// le retour = nouvelle vue ; le refresh/même page ne multiplie pas les événements ;
|
||||
/// l'écran initial est émis une fois. L'émission est centralisée dans
|
||||
/// <see cref="ScreenUsageTelemetry"/> (jamais par ViewModel).
|
||||
/// </summary>
|
||||
public class ScreenUsageTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
public void TrackInitialScreen_EmitsSingleScreenView_ForCurrentPage()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, null);
|
||||
|
||||
tracker.TrackInitialScreen();
|
||||
|
||||
OmikaEvent evt = Assert.Single(capturing.Events);
|
||||
Assert.Equal(TelemetryEventCodes.MobileScreenView, evt.EventCode);
|
||||
Assert.Equal(DiagnosticScreenCodes.Agenda, evt.ScreenCode);
|
||||
Assert.Null(evt.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigate_EmitsExactlyOneScreenView_ForTarget()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, null);
|
||||
tracker.TrackInitialScreen();
|
||||
capturing.Clear();
|
||||
|
||||
navigation.NavigateTo(AppPage.SiteDetail);
|
||||
|
||||
OmikaEvent evt = Assert.Single(capturing.Events);
|
||||
Assert.Equal(TelemetryEventCodes.MobileScreenView, evt.EventCode);
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, evt.ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Return_IsANewScreenView()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, null);
|
||||
tracker.TrackInitialScreen(); // AGENDA (1)
|
||||
navigation.NavigateTo(AppPage.SiteDetail); // SITE_DETAIL (2)
|
||||
navigation.GoBack(); // AGENDA (3)
|
||||
|
||||
Assert.Equal(3, capturing.Events.Count);
|
||||
Assert.Equal(DiagnosticScreenCodes.Agenda, capturing.Events[0].ScreenCode);
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, capturing.Events[1].ScreenCode);
|
||||
Assert.Equal(DiagnosticScreenCodes.Agenda, capturing.Events[2].ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_OrSamePage_DoesNotEmitAnotherScreenView()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, null);
|
||||
tracker.TrackInitialScreen(); // AGENDA (1)
|
||||
navigation.NavigateTo(AppPage.SiteDetail); // SITE_DETAIL (2)
|
||||
|
||||
// Même page (paramètre identique ou différent) : NavigationService ne lève
|
||||
// pas PageChanged (seulement ParameterChanged) → aucun ScreenView.
|
||||
navigation.NavigateTo(AppPage.SiteDetail);
|
||||
navigation.NavigateTo(AppPage.SiteDetail, new object());
|
||||
|
||||
Assert.Equal(2, capturing.Events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigate_SetsCurrentScreenOnDiagnosticContext()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var context = new DiagnosticContext(new DiagnosticSession(), new DeviceInfoSnapshot());
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, context);
|
||||
tracker.TrackInitialScreen();
|
||||
|
||||
navigation.NavigateTo(AppPage.InterventionDetail);
|
||||
|
||||
Assert.Equal(DiagnosticScreenCodes.InterventionDetail, context.ScreenCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownPage_DoesNotEmit()
|
||||
{
|
||||
var navigation = new NavigationService();
|
||||
var capturing = new CapturingTelemetry();
|
||||
var tracker = new ScreenUsageTelemetry(navigation, capturing, null);
|
||||
|
||||
// Aucune page inconnue n'est navigable, mais le tracker reste fail-open :
|
||||
// la navigation vers une page non résolue n'émet rien.
|
||||
tracker.TrackInitialScreen();
|
||||
capturing.Clear();
|
||||
|
||||
navigation.NavigateTo(AppPage.Parametres);
|
||||
Assert.Single(capturing.Events);
|
||||
Assert.Equal(DiagnosticScreenCodes.Settings, capturing.Events[0].ScreenCode);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public void Clear() => Events.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Collection de tests partageant l'état statique global
|
||||
/// <see cref="Omika.Core.Observability.ExceptionTelemetry"/> (façade de télémétrie
|
||||
/// branchée par <c>Initialize</c>).
|
||||
///
|
||||
/// xUnit exécute les tests d'une même collection séquentiellement : sans cela, un test
|
||||
/// qui branche sa propre capture pendant qu'un autre affirme <c>Assert.Single</c> sur
|
||||
/// la sienne recevrait ou perdrait des événements de façon intermittente.
|
||||
/// </summary>
|
||||
[CollectionDefinition("TelemetrieStatique")]
|
||||
public class TelemetrieStatiqueCollection
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class TelemetryEventCodesTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllCodes_AreValid()
|
||||
{
|
||||
foreach (EventCode code in GetEventCodes())
|
||||
{
|
||||
Assert.True(code.IsValid, $"Le code {code} doit être valide.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllCodes_AreUnique()
|
||||
{
|
||||
string[] values = GetEventCodes().Select(c => c.Value!).ToArray();
|
||||
|
||||
Assert.Equal(values.Length, values.Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
private static IEnumerable<EventCode> GetEventCodes() =>
|
||||
typeof(TelemetryEventCodes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.FieldType == typeof(EventCode))
|
||||
.Select(f => (EventCode)f.GetValue(null)!);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Contrat du chemin chaud <c>Track()</c> : il doit rester synchrone, rapide et
|
||||
/// sans exception dans tous les états du pipeline (désactivé, file vide, presque
|
||||
/// pleine, pleine, tempête). Aucun IO réseau/fichier/base n'est réalisé : le
|
||||
/// chemin est purement en mémoire.
|
||||
/// </summary>
|
||||
public class TelemetryHotPathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Track_Disabled_ReturnsImmediately()
|
||||
{
|
||||
var options = new ObservabilityOptions { Enabled = false };
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.DroppedDisabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_EmptyQueue_Accepts()
|
||||
{
|
||||
var (sink, queue, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_NearFullQueue_Accepts()
|
||||
{
|
||||
var options = new ObservabilityOptions
|
||||
{
|
||||
P0QueueCapacity = 1,
|
||||
P1QueueCapacity = 1,
|
||||
P2QueueCapacity = 1,
|
||||
P3QueueCapacity = 1,
|
||||
P4QueueCapacity = 1
|
||||
};
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_FullQueue_DropsWithoutThrow()
|
||||
{
|
||||
var options = new ObservabilityOptions
|
||||
{
|
||||
P0QueueCapacity = 1,
|
||||
P1QueueCapacity = 1,
|
||||
P2QueueCapacity = 1,
|
||||
P3QueueCapacity = 1,
|
||||
P4QueueCapacity = 1
|
||||
};
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline(options);
|
||||
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
Assert.Equal(1L, counters.Accepted);
|
||||
Assert.Equal(1L, counters.DroppedQueueFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_LogStorm_DoesNotThrow()
|
||||
{
|
||||
var (sink, _, counters) = ObservabilityTestHarness.CreatePipeline();
|
||||
|
||||
for (int i = 0; i < 100_000; i++)
|
||||
{
|
||||
sink.Track(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
}
|
||||
|
||||
// Aucune exception ; le flux est borné et comptabilisé.
|
||||
Assert.Equal(100_000L, counters.Accepted + counters.DroppedTotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class TelemetryMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_RetainsValidEntries()
|
||||
{
|
||||
TelemetryMetadata metadata = TelemetryMetadata.Create(("count", 42), ("ok", true));
|
||||
|
||||
Assert.Equal(2, metadata.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DropsNullKey()
|
||||
{
|
||||
TelemetryMetadata metadata = TelemetryMetadata.Create((null!, 42));
|
||||
|
||||
Assert.Equal(0, metadata.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DropsWhitespaceKey()
|
||||
{
|
||||
TelemetryMetadata metadata = TelemetryMetadata.Create((" ", 42));
|
||||
|
||||
Assert.Equal(0, metadata.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DropsInvalidValue()
|
||||
{
|
||||
TelemetryMetadata metadata = TelemetryMetadata.Create(("bad", TelemetryValue.Invalid));
|
||||
|
||||
Assert.Equal(0, metadata.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DeduplicatesByKey_FirstWins()
|
||||
{
|
||||
TelemetryMetadata metadata = TelemetryMetadata.Create(("k", 1), ("k", 2));
|
||||
|
||||
Assert.Equal(1, metadata.Count);
|
||||
Assert.Equal(1, (int)metadata.Entries[0].Value.Value!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_Empty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Same(TelemetryMetadata.Empty, TelemetryMetadata.Create());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Same(TelemetryMetadata.Empty, TelemetryMetadata.Create((IEnumerable<KeyValuePair<string, TelemetryValue>>)null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class TelemetryValueTests
|
||||
{
|
||||
private enum SampleEnum
|
||||
{
|
||||
A,
|
||||
B
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_IsValid()
|
||||
{
|
||||
TelemetryValue value = "hello";
|
||||
|
||||
Assert.True(value.IsValid);
|
||||
Assert.Equal(TelemetryValueKind.String, value.Kind);
|
||||
Assert.Equal("hello", value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullString_IsInvalid()
|
||||
{
|
||||
TelemetryValue value = (string)null!;
|
||||
|
||||
Assert.False(value.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void NonFiniteDouble_IsInvalid(double number)
|
||||
{
|
||||
TelemetryValue value = number;
|
||||
|
||||
Assert.False(value.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimpleTypes_AreValid()
|
||||
{
|
||||
Assert.True(((TelemetryValue)true).IsValid);
|
||||
Assert.True(((TelemetryValue)42).IsValid);
|
||||
Assert.True(((TelemetryValue)42L).IsValid);
|
||||
Assert.True(((TelemetryValue)1.5).IsValid);
|
||||
Assert.True(((TelemetryValue)Guid.NewGuid()).IsValid);
|
||||
Assert.True(((TelemetryValue)DateTimeOffset.UtcNow).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEnum_IsValid_AndStoresName()
|
||||
{
|
||||
TelemetryValue value = TelemetryValue.FromEnum(SampleEnum.B);
|
||||
|
||||
Assert.True(value.IsValid);
|
||||
Assert.Equal(TelemetryValueKind.String, value.Kind);
|
||||
Assert.Equal("B", value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsInvalid()
|
||||
{
|
||||
Assert.False(default(TelemetryValue).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability;
|
||||
|
||||
public class TelemetryWorkerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_DrainsToExporter()
|
||||
{
|
||||
var (queue, counters) = CreateQueue();
|
||||
var exporter = new CollectingExporter();
|
||||
var worker = new TelemetryWorker(queue, exporter);
|
||||
|
||||
queue.TryEnqueue(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
queue.TryEnqueue(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = worker.ExecuteAsync(cts.Token);
|
||||
|
||||
await ObservabilityTestHarness.WaitUntilAsync(() => exporter.Count == 2);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_DoesNotStarveLowPriority()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
var exporter = new CollectingExporter();
|
||||
var worker = new TelemetryWorker(queue, exporter);
|
||||
|
||||
// Fort arriéré de P0 + quelques P4 : les P4 doivent quand même être exportés.
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
queue.TryEnqueue(ObservabilityTestHarness.NewEvent(
|
||||
TelemetryEventCodes.MobileSyncOperationReceived, priority: TelemetryPriority.P0));
|
||||
}
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
queue.TryEnqueue(ObservabilityTestHarness.NewEvent(
|
||||
TelemetryEventCodes.MobileAppStarted, priority: TelemetryPriority.P4));
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = worker.ExecuteAsync(cts.Token);
|
||||
|
||||
await ObservabilityTestHarness.WaitUntilAsync(() => exporter.Count == 110);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
|
||||
Assert.Equal(100, exporter.ByPriority(TelemetryPriority.P0));
|
||||
Assert.Equal(10, exporter.ByPriority(TelemetryPriority.P4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_ExporterThrows_DoesNotPropagate()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
var worker = new TelemetryWorker(queue, new ThrowingExporter());
|
||||
|
||||
queue.TryEnqueue(ObservabilityTestHarness.NewEvent(TelemetryEventCodes.MobileAppStarted));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = worker.ExecuteAsync(cts.Token);
|
||||
|
||||
// Laisse au worker le temps de tenter l'export, d'échouer et de reprendre.
|
||||
await Task.Delay(300);
|
||||
cts.Cancel();
|
||||
|
||||
await run;
|
||||
// Le lot a été abandonné (perte de log acceptable) ; aucune exception.
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_Cancellation_Stops()
|
||||
{
|
||||
var (queue, _) = CreateQueue();
|
||||
var worker = new TelemetryWorker(queue, new NullTelemetryExporter());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = worker.ExecuteAsync(cts.Token);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
private static (PriorityTelemetryQueue Queue, TelemetryCounters Counters) CreateQueue()
|
||||
{
|
||||
var options = new ObservabilityOptions();
|
||||
var counters = new TelemetryCounters();
|
||||
return (new PriorityTelemetryQueue(options, counters), counters);
|
||||
}
|
||||
|
||||
private sealed class CollectingExporter : ITelemetryExporter
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<OmikaEvent> _exported = [];
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _exported.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ByPriority(TelemetryPriority priority)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _exported.Count(e => e.EffectivePriority == priority);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ExportAsync(IReadOnlyList<OmikaEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_exported.AddRange(events);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingExporter : ITelemetryExporter
|
||||
{
|
||||
public Task ExportAsync(IReadOnlyList<OmikaEvent> events, CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("exporteur défaillant (test)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user