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; /// Tests de l'orchestrateur du forwarder (ACK → checkpoint/suppression, erreurs, reprise). 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.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.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.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.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.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.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.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.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 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 GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(new DiagnosticDeploymentContext { ClientCode = clientCode }); } }