using Microsoft.Extensions.Logging.Abstractions; using Omika.Core.Configuration; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Interfaces.Mobile; using Omika.Core.Observability.Deployment; using Omika.Core.Observability.Identity; using Omika.Core.Observability.Outbox; using Omika.Data.Connection; using Omika.Worker; namespace Omika.Tests.Mobile; /// /// Tests du traitement des opérations de synchronisation serveur (SyncProcessor). /// public class SyncProcessorTests { private static SyncOptions DefaultOptions() => new() { MaxRetryCount = 30, RetryDelaySeconds = 120, LogRetentionDays = 30 }; private static SyncEnvelope CreateEnvelope( SyncOperationType type, Guid? operationId = null, int retryCount = 0, string deviceId = "device-1") { return new SyncEnvelope { OperationId = operationId ?? Guid.NewGuid(), DeviceId = deviceId, EntityType = "Client", OperationType = type, EntityGuid = Guid.NewGuid(), RetryCount = retryCount, Payload = "{}", ReceivedAtUtc = DateTimeOffset.UtcNow }; } private static SyncProcessor CreateProcessor( FakeSyncTransferRepository repo, ISyncOperationHandler handler, SyncOptions? options = null, IWebServiceTransactionContext? transactionContext = null, IRemoteDiagnosticIngestionStore? store = null, IDiagnosticDeploymentContextProvider? deploymentContextProvider = null) { var registry = new SyncOperationHandlerRegistry(new[] { handler }); return new SyncProcessor( repo, registry, new DefaultSyncOperationHandler(), options ?? DefaultOptions(), NullLogger.Instance, transactionContext ?? new FakeWebServiceTransactionContext(), new WorkerDiagnosticEmitter( store ?? new NullDiagnosticStore(), deploymentContextProvider ?? new EmptyDeploymentContextProvider())); } [Fact] public async Task Success_On_First_Pass_Marks_Success_And_Logs_Success() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.Success()); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Act bool handled = await processor.ProcessOperationAsync(env); // Assert Assert.True(handled); Assert.Contains(env.OperationId, repo.Succeeded); Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId); } [Fact] public async Task Temporary_Error_Increments_RetryCount() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Update); // Act bool handled = await processor.ProcessOperationAsync(env); // Assert Assert.False(handled); Assert.Equal(1, repo.RetryCounts[env.OperationId]); } [Fact] public async Task Success_After_Retry_Is_Processed() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Premier passage : erreur temporaire. await processor.ProcessOperationAsync(env); // Le handler réussit au second passage. handler.Result = SyncOperationResult.Success(); var envRetry = env with { RetryCount = 1 }; // Act bool handled = await processor.ProcessOperationAsync(envRetry); // Assert Assert.True(handled); Assert.Contains(env.OperationId, repo.Succeeded); Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId); } [Fact] public async Task Rejected_After_Max_Retry_Count() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var options = DefaultOptions(); var processor = CreateProcessor(repo, handler, options); var env = CreateEnvelope(SyncOperationType.Delete, retryCount: options.MaxRetryCount); // Act bool handled = await processor.ProcessOperationAsync(env); // Assert Assert.True(handled); Assert.Contains(env.OperationId, repo.Rejected); Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Rejected && l.OperationId == env.OperationId); } [Fact] public async Task Permanent_Failure_Is_Rejected_Immediately() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.PermanentFailure("NO_HANDLER")); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Act bool handled = await processor.ProcessOperationAsync(env); // Assert : un échec définitif ne consomme pas 30 retries. Assert.True(handled); Assert.Contains(env.OperationId, repo.Rejected); Assert.False(repo.RetryCounts.ContainsKey(env.OperationId)); Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Rejected && l.OperationId == env.OperationId); } [Fact] public async Task Create_Is_Processed_Before_Update() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.Success()); var processor = CreateProcessor(repo, handler); var updateEnv = CreateEnvelope(SyncOperationType.Update); var createEnv = CreateEnvelope(SyncOperationType.Create); repo.Pending.Add(updateEnv); repo.Pending.Add(createEnv); // Act await processor.ProcessAsync(); // Assert : CREATE traité avant UPDATE. int createIndex = repo.ProcessedOrder.IndexOf(createEnv.OperationId); int updateIndex = repo.ProcessedOrder.IndexOf(updateEnv.OperationId); Assert.True(createIndex >= 0); Assert.True(updateIndex >= 0); Assert.True(createIndex < updateIndex); } [Fact] public async Task Update_Is_Processed_Before_Delete() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.Success()); var processor = CreateProcessor(repo, handler); var deleteEnv = CreateEnvelope(SyncOperationType.Delete); var updateEnv = CreateEnvelope(SyncOperationType.Update); repo.Pending.Add(deleteEnv); repo.Pending.Add(updateEnv); // Act await processor.ProcessAsync(); // Assert : UPDATE traité avant DELETE. int updateIndex = repo.ProcessedOrder.IndexOf(updateEnv.OperationId); int deleteIndex = repo.ProcessedOrder.IndexOf(deleteEnv.OperationId); Assert.True(updateIndex >= 0); Assert.True(deleteIndex >= 0); Assert.True(updateIndex < deleteIndex); } [Fact] public async Task OperationId_Is_Preserved_Through_Retry() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Act await processor.ProcessOperationAsync(env); // Assert : OperationId inchangé pendant le retry. Assert.Equal(env.OperationId, repo.RetryCounts.Keys.Single()); } [Fact] public async Task Transfer_Is_Called_Before_Processing() { // Arrange var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.Success()); var processor = CreateProcessor(repo, handler); // Act await processor.ProcessAsync(); // Assert : le transfert TOSRV → MobReception a été appelé. Assert.True(repo.TransferCalled); } [Fact] public async Task Handler_Receives_Common_Contract_Regardless_Of_Generation() { // Arrange : le handler public ne voit jamais de différence Batik/Omika. var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.Success()); var processor = CreateProcessor(repo, handler); // Deux enveloppes identiques au niveau du contrat commun, quelle que soit // la génération de base (Batik ou Omika). Le handler ne reçoit que SyncOperation. var env = CreateEnvelope(SyncOperationType.Create); // Act await processor.ProcessOperationAsync(env); // Assert : le handler a reçu un contrat commun SyncOperation. Assert.NotNull(handler.LastOperation); Assert.Equal(env.OperationId, handler.LastOperation.OperationId); Assert.Equal(env.EntityType, handler.LastOperation.EntityType); Assert.Equal(env.OperationType, handler.LastOperation.OperationType); Assert.Equal(env.EntityGuid, handler.LastOperation.EntityGuid); Assert.Equal(env.DeviceId, handler.LastOperation.DeviceId); } [Fact] public async Task Duplicate_OperationId_Does_Not_Reapply_Handler() { // Arrange : le handler réussit et enregistre un CanonicalEntityGuid. var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler( SyncOperationResult.Success("canonical-1", Guid.NewGuid(), Guid.NewGuid())); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Premier traitement : application métier réelle. await processor.ProcessOperationAsync(env); int handlerCallsAfterFirst = handler.CallCount; // Act : le même OperationId est présenté à nouveau (ex. doublon MobReception). bool handled = await processor.ProcessOperationAsync(env); // Assert : l'opération est considérée comme traitée, mais le handler n'est // PAS rappelé (aucune seconde création métier). Assert.True(handled); Assert.Equal(handlerCallsAfterFirst, handler.CallCount); Assert.Contains(env.OperationId, repo.Succeeded); } [Fact] public async Task Duplicate_OperationId_Rejected_Is_Not_Reapplied() { // Arrange : le handler échoue définitivement → REJECTED. var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler(SyncOperationResult.PermanentFailure("NO_HANDLER")); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Premier traitement : rejet définitif. await processor.ProcessOperationAsync(env); int handlerCallsAfterFirst = handler.CallCount; // Act : le même OperationId est présenté à nouveau. bool handled = await processor.ProcessOperationAsync(env); // Assert : l'opération reste rejetée, le handler n'est pas rappelé. Assert.True(handled); Assert.Equal(handlerCallsAfterFirst, handler.CallCount); Assert.Contains(env.OperationId, repo.Rejected); } [Fact] public async Task Duplicate_OperationId_Preserves_CanonicalEntityGuid() { // Arrange : le handler réussit avec un CanonicalEntityGuid. var requested = Guid.NewGuid(); var canonical = Guid.NewGuid(); var repo = new FakeSyncTransferRepository(); var handler = new FakeSyncOperationHandler( SyncOperationResult.Success("canonical-1", requested, canonical)); var processor = CreateProcessor(repo, handler); var env = CreateEnvelope(SyncOperationType.Create); // Premier traitement. await processor.ProcessOperationAsync(env); // Act : le même OperationId est présenté à nouveau. await processor.ProcessOperationAsync(env); // Assert : le CanonicalEntityGuid d'origine est conservé dans le log // terminal reconstitué (idempotence par OperationId). SyncLogEntry? terminal = repo.Logs .Where(l => l.OperationId == env.OperationId && l.Status == SyncStatus.Success) .LastOrDefault(); Assert.NotNull(terminal); Assert.Equal(canonical, terminal.CanonicalEntityGuid); Assert.Equal(requested, terminal.RequestedEntityGuid); Assert.Equal("canonical-1", terminal.CreatedEntityId); } // ===== Tests transactionnels (idempotence retry) ===== [Fact] public async Task Rollback_On_Exception_Before_MobLog_Leaves_No_Durable_Business() { // Arrange : un handler qui écrit une Intervention dans la transaction, puis // lève une exception avant que MobLog SUCCESS ne soit écrit. var repo = new FakeSyncTransferRepository(); var tx = new FakeTransactionalWebServiceTransactionContext(); var handler = new ThrowingAfterWriteHandler(tx); var processor = CreateProcessor(repo, handler, transactionContext: tx); var env = CreateEnvelope(SyncOperationType.Create); // Act : le handler écrit l'Intervention puis lève une exception retryable. bool handled = await processor.ProcessOperationAsync(env); // Assert : ROLLBACK → aucune Intervention durable, aucun MobLog SUCCESS. Assert.False(handled); Assert.Equal(0, tx.CommittedInterventions.Count); Assert.Equal(0, repo.Succeeded.Count); Assert.DoesNotContain(repo.Logs, l => l.Status == SyncStatus.Success); Assert.Equal(1, tx.RollbackCount); } [Fact] public async Task Retry_After_Rollback_Commits_Business_And_MobLog() { // Arrange : premier passage → exception (rollback), second passage → succès. var repo = new FakeSyncTransferRepository(); var tx = new FakeTransactionalWebServiceTransactionContext(); var handler = new ThrowingThenSuccessHandler(tx); var processor = CreateProcessor(repo, handler, transactionContext: tx); var env = CreateEnvelope(SyncOperationType.Create); // Premier passage : exception → rollback, aucune écriture durable. bool firstHandled = await processor.ProcessOperationAsync(env); Assert.False(firstHandled); Assert.Equal(0, tx.CommittedInterventions.Count); Assert.Equal(0, repo.Succeeded.Count); // Act : retry → le handler réussit → COMMIT atomique (Intervention + MobLog). var envRetry = env with { RetryCount = 1 }; bool secondHandled = await processor.ProcessOperationAsync(envRetry); // Assert : Intervention = 1, MobLog SUCCESS = 1. Assert.True(secondHandled); Assert.Equal(1, tx.CommittedInterventions.Count); Assert.Equal(1, repo.Succeeded.Count); Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId); } [Fact] public async Task Crash_After_Commit_Does_Not_Reapply_Handler_On_Retry() { // Arrange : premier passage → succès complet (Intervention + MobLog + COMMIT). var repo = new FakeSyncTransferRepository(); var tx = new FakeTransactionalWebServiceTransactionContext(); var handler = new SuccessWritingHandler(tx); var processor = CreateProcessor(repo, handler, transactionContext: tx); var env = CreateEnvelope(SyncOperationType.Create); // Premier traitement : COMMIT réussi (Intervention + MobLog SUCCESS durables). bool firstHandled = await processor.ProcessOperationAsync(env); Assert.True(firstHandled); Assert.Equal(1, tx.CommittedInterventions.Count); Assert.Equal(1, repo.Succeeded.Count); int handlerCallsAfterFirst = handler.CallCount; // Act : simulation crash après COMMIT → le même OperationId est rejoué. bool replayedHandled = await processor.ProcessOperationAsync(env); // Assert : GetTerminalResultAsync → SUCCESS → handler NON rappelé, // aucune Intervention supplémentaire. Le log terminal SUCCESS est // reconstitué (re-marquage) sans réapplication métier : l'OperationId // reste SUCCESS, mais le handler n'est pas rappelé et aucune nouvelle // Intervention n'est durable. Assert.True(replayedHandled); Assert.Equal(handlerCallsAfterFirst, handler.CallCount); Assert.Equal(1, tx.CommittedInterventions.Count); Assert.Contains(env.OperationId, repo.Succeeded); } [Fact] public async Task Two_Different_OperationIds_Produce_Two_Business_Creations() { // Arrange : deux OperationId réellement différents A et B. var repo = new FakeSyncTransferRepository(); var tx = new FakeTransactionalWebServiceTransactionContext(); var handler = new SuccessWritingHandler(tx); var processor = CreateProcessor(repo, handler, transactionContext: tx); var envA = CreateEnvelope(SyncOperationType.Create); var envB = CreateEnvelope(SyncOperationType.Create); // Act : deux opérations distinctes. bool handledA = await processor.ProcessOperationAsync(envA); bool handledB = await processor.ProcessOperationAsync(envB); // Assert : 2 créations métier durables, 2 MobLog SUCCESS. Assert.True(handledA); Assert.True(handledB); Assert.Equal(2, tx.CommittedInterventions.Count); Assert.Equal(2, repo.Succeeded.Count); Assert.Contains(envA.OperationId, repo.Succeeded); Assert.Contains(envB.OperationId, repo.Succeeded); } // ===== Fakes ===== private sealed class FakeSyncTransferRepository : ISyncTransferRepository { public List Pending { get; } = new(); public List Succeeded { get; } = new(); public List Rejected { get; } = new(); public List ProcessedOrder { get; } = new(); public List Logs { get; } = new(); public Dictionary RetryCounts { get; } = new(); public bool TransferCalled { get; private set; } public Task ExistsAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(Succeeded.Contains(operationId) || Rejected.Contains(operationId)); public Task GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default) { if (Succeeded.Contains(operationId)) { return Task.FromResult(SyncOperationState.Success); } if (Rejected.Contains(operationId)) { return Task.FromResult(SyncOperationState.Rejected); } return Task.FromResult(SyncOperationState.Unknown); } public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default) { Pending.Add(envelope); return Task.CompletedTask; } public Task GetStatusAsync(Guid operationId, string deviceId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetTerminalResultAsync(Guid operationId, CancellationToken cancellationToken = default) { // Retourne le dernier log terminal (SUCCESS / REJECTED) pour l'opération. SyncLogEntry? terminal = Logs .Where(l => l.OperationId == operationId && (l.Status == SyncStatus.Success || l.Status == SyncStatus.Rejected)) .LastOrDefault(); return Task.FromResult(terminal); } public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) { Logs.Add(entry); return Task.CompletedTask; } public Task PurgeLogsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default) => Task.FromResult(0); public Task TransferToReceptionAsync(CancellationToken cancellationToken = default) { TransferCalled = true; return Task.FromResult(0); } public Task> GetPendingOperationsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Pending.ToList()); public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) { Succeeded.Add(entry.OperationId); ProcessedOrder.Add(entry.OperationId); Logs.Add(entry); return Task.CompletedTask; } public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) { Rejected.Add(entry.OperationId); ProcessedOrder.Add(entry.OperationId); Logs.Add(entry); return Task.CompletedTask; } public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default) { RetryCounts[operationId] = newRetryCount; return Task.CompletedTask; } } private sealed class FakeSyncOperationHandler : ISyncOperationHandler { public SyncOperationResult Result { get; set; } public SyncOperation? LastOperation { get; private set; } public int CallCount { get; private set; } public FakeSyncOperationHandler(SyncOperationResult result) { Result = result; } public IReadOnlyCollection SupportedEntityTypes => new[] { "Client" }; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) { LastOperation = operation; CallCount++; return Task.FromResult(Result); } } /// /// Fake de session transactionnelle BATIK_TEST : enregistre les appels /// Begin / Commit / Rollback pour vérifier l'orchestration transactionnelle /// du SyncProcessor sans dépendre d'une connexion OLE DB réelle. /// private sealed class FakeWebServiceTransactionContext : IWebServiceTransactionContext { public IHfsqlWriteConnection Connection => throw new NotSupportedException( "Le fake de transaction n'expose pas de connexion physique."); public int BeginCount { get; private set; } public int CommitCount { get; private set; } public int RollbackCount { get; private set; } public Task BeginAsync(CancellationToken cancellationToken = default) { BeginCount++; return Task.CompletedTask; } public Task CommitAsync(CancellationToken cancellationToken = default) { CommitCount++; return Task.CompletedTask; } public Task RollbackAsync(CancellationToken cancellationToken = default) { RollbackCount++; return Task.CompletedTask; } } /// /// Fake de session transactionnelle BATIK_TEST simulant une base transactionnelle : /// les écritures métier (Intervention) sont accumulées en attente pendant la /// transaction, puis validées (Commit) ou annulées (Rollback). Permet de vérifier /// qu'aucune Intervention partielle n'est durable après un rollback. /// private sealed class FakeTransactionalWebServiceTransactionContext : IWebServiceTransactionContext { private readonly List _pendingInterventions = new(); /// Interventions rendues durables par un COMMIT. public List CommittedInterventions { get; } = new(); public int BeginCount { get; private set; } public int CommitCount { get; private set; } public int RollbackCount { get; private set; } public IHfsqlWriteConnection Connection => throw new NotSupportedException( "Le fake de transaction n'expose pas de connexion physique."); /// Enregistre une écriture métier Intervention dans la transaction en cours. public void WriteIntervention(Guid operationId) => _pendingInterventions.Add(operationId); public Task BeginAsync(CancellationToken cancellationToken = default) { BeginCount++; return Task.CompletedTask; } public Task CommitAsync(CancellationToken cancellationToken = default) { CommitCount++; CommittedInterventions.AddRange(_pendingInterventions); _pendingInterventions.Clear(); return Task.CompletedTask; } public Task RollbackAsync(CancellationToken cancellationToken = default) { RollbackCount++; _pendingInterventions.Clear(); return Task.CompletedTask; } } /// /// Handler qui écrit une Intervention dans la transaction puis lève une exception /// (simule un crash avant l'écriture de MobLog SUCCESS). /// private sealed class ThrowingAfterWriteHandler : ISyncOperationHandler { private readonly FakeTransactionalWebServiceTransactionContext _tx; public ThrowingAfterWriteHandler(FakeTransactionalWebServiceTransactionContext tx) { _tx = tx; } public IReadOnlyCollection SupportedEntityTypes => new[] { "Client" }; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) { // Écriture métier Intervention dans la transaction. _tx.WriteIntervention(operation.OperationId); // Puis exception retryable avant MobLog SUCCESS. throw new InvalidOperationException("Crash simulé avant MobLog SUCCESS."); } } /// /// Handler qui écrit une Intervention et lève une exception au premier appel, /// puis réussit au second appel (simule un retry après rollback). /// private sealed class ThrowingThenSuccessHandler : ISyncOperationHandler { private readonly FakeTransactionalWebServiceTransactionContext _tx; private int _calls; public ThrowingThenSuccessHandler(FakeTransactionalWebServiceTransactionContext tx) { _tx = tx; } public IReadOnlyCollection SupportedEntityTypes => new[] { "Client" }; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) { _calls++; _tx.WriteIntervention(operation.OperationId); if (_calls == 1) { throw new InvalidOperationException("Erreur transitoire simulée."); } return Task.FromResult(SyncOperationResult.Success()); } } /// /// Handler qui écrit une Intervention dans la transaction puis retourne SUCCESS /// (simule une création métier réussie, durable après COMMIT). /// private sealed class SuccessWritingHandler : ISyncOperationHandler { private readonly FakeTransactionalWebServiceTransactionContext _tx; public SuccessWritingHandler(FakeTransactionalWebServiceTransactionContext tx) { _tx = tx; } public IReadOnlyCollection SupportedEntityTypes => new[] { "Client" }; public int CallCount { get; private set; } public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) { CallCount++; // Écriture métier Intervention dans la transaction. _tx.WriteIntervention(operation.OperationId); return Task.FromResult(SyncOperationResult.Success()); } } /// Store Diagnostics no-op : les tests SyncProcessor historiques ne /// vérifient pas l'émission d'événements (couvert par WorkerSyncDiagnosticsTests). private sealed class NullDiagnosticStore : IRemoteDiagnosticIngestionStore { public Task StoreAsync( DiagnosticBatchDto batch, DiagnosticActorKey actorKey, string ingestionTraceId, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken = default, DiagnosticDeploymentContext? deploymentContext = null, DiagnosticDeviceKey? deviceKey = null, string? licenseNumber = null) => Task.FromResult(batch.Events.Count); } /// Provider de contexte de déploiement vide (fail-open). private sealed class EmptyDeploymentContextProvider : IDiagnosticDeploymentContextProvider { public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(DiagnosticDeploymentContext.Empty); } }