Premiere commit - Omika

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