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 });
}
}