using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Omika.Core.Configuration; using Omika.Core.Constants; 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; /// /// Tâche 13-E — instrumentation du Worker métier (SyncProcessor) dans le pipeline /// Diagnostics. Vérifie l'émission réelle de : /// /// OMK-WKR-SYNC-001 (succès, Info/P3) ; /// OMK-WKR-SYNC-002 (échec définitif, Error/P1) ; /// contexte ClientCode/ServerInstance/Environment toujours issu du provider /// (jamais de l'opération/payload) ; /// fail-open absolu (provider sans ClientCode, store en panne) ; /// corrélation par OperationId (aucun faux TraceId inventé). /// /// Aucune base HFSQL/PostgreSQL réelle : fakes en mémoire uniquement. /// public class WorkerSyncDiagnosticsTests { private static SyncOptions DefaultOptions() => new() { MaxRetryCount = 30, RetryDelaySeconds = 120, LogRetentionDays = 30 }; private static SyncEnvelope CreateEnvelope( SyncOperationType type, Guid? operationId = null, int retryCount = 0, string? payload = null) => new() { OperationId = operationId ?? Guid.NewGuid(), DeviceId = "device-1", ClientId = "FAKE-CLIENT-ID", UserId = "user-1", CollaborateurId = "collab-1", EntityType = "Client", OperationType = type, EntityGuid = Guid.NewGuid(), RetryCount = retryCount, Payload = payload ?? "{}", ReceivedAtUtc = DateTimeOffset.UtcNow }; private static FakeDeploymentContextProvider Provider( string? clientCode = "CLIENT-A", string serverInstance = "WKR-SRV-01", string environment = "Production") => new() { Context = new DiagnosticDeploymentContext { ClientCode = clientCode, ServerInstance = serverInstance, Environment = environment } }; private static SyncProcessor CreateProcessor( FakeSyncRepository repo, ISyncOperationHandler handler, SyncOptions? options = null, IWebServiceTransactionContext? transactionContext = null, IRemoteDiagnosticIngestionStore? store = null, IDiagnosticDeploymentContextProvider? provider = null) { var registry = new SyncOperationHandlerRegistry(new[] { handler }); return new SyncProcessor( repo, registry, new DefaultSyncOperationHandler(), options ?? DefaultOptions(), NullLogger.Instance, transactionContext ?? new FakeTransaction(), new WorkerDiagnosticEmitter( store ?? new FakeDiagnosticStore(), provider ?? Provider())); } // --------------------------- E1 : succès ----------------------------------- [Fact] public async Task E1_Success_EmitsWorkerSyncProcessed_WithDeploymentContext() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var provider = Provider(); var processor = CreateProcessor(repo, handler, store: store, provider: provider); var envelope = CreateEnvelope(SyncOperationType.Create); bool handled = await processor.ProcessOperationAsync(envelope); Assert.True(handled); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); Assert.Equal("OMK-WKR-SYNC-001", evt.EventCode); Assert.Equal("Info", evt.Level); Assert.Equal("P3", evt.Priority); Assert.Equal(SyncProcessor.DiagnosticComponent, evt.Component); Assert.Equal("CLIENT-A", call.Context!.ClientCode); Assert.Equal("WKR-SRV-01", call.Context.ServerInstance); Assert.Equal("Production", call.Context.Environment); } // --------------------------- E2 : échec définitif -------------------------- [Fact] public async Task E2_PermanentFailure_EmitsWorkerSyncFailed_ErrorP1() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.PermanentFailure("NO_HANDLER", "Aucun handler")); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Create); bool handled = await processor.ProcessOperationAsync(envelope); Assert.True(handled); Assert.Contains(envelope.OperationId, repo.Rejected); // mécanisme métier intact. (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode); Assert.Equal("Error", evt.Level); Assert.Equal("P1", evt.Priority); Assert.Equal("NO_HANDLER", evt.ErrorCode); Assert.False(evt.Success); Assert.Equal("CLIENT-A", call.Context!.ClientCode); Assert.Equal("WKR-SRV-01", call.Context.ServerInstance); Assert.Equal("Production", call.Context.Environment); } // --------------------------- E3 : fail-open provider ----------------------- [Fact] public async Task E3_ProviderWithoutClientCode_EventStillWrittenWithNull() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider(clientCode: null)); var envelope = CreateEnvelope(SyncOperationType.Create); bool handled = await processor.ProcessOperationAsync(envelope); Assert.True(handled); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); Assert.Null(call.Context!.ClientCode); Assert.Equal("WKR-SRV-01", call.Context.ServerInstance); Assert.Equal("Production", call.Context.Environment); } // --------------------------- E4 : store en panne --------------------------- [Fact] public async Task E4_StoreFailure_DoesNotBreakBusinessProcessing() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(throwOnStore: true); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Create); bool handled = await processor.ProcessOperationAsync(envelope); // Fail-open : la panne du store n'a pas transformé le succès métier en échec. Assert.True(handled); Assert.Contains(envelope.OperationId, repo.Succeeded); } // --------------------------- E5 : contexte non falsifiable ----------------- [Fact] public async Task E5_OperationIdentities_NeverReplaceProviderClientCode() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider(clientCode: "CLIENT-A")); var envelope = CreateEnvelope(SyncOperationType.Create); await processor.ProcessOperationAsync(envelope); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); Assert.Equal("CLIENT-A", call.Context!.ClientCode); string json = JsonSerializer.Serialize(call.Batch); Assert.DoesNotContain("FAKE-CLIENT-ID", json, StringComparison.Ordinal); Assert.DoesNotContain("device-1", json, StringComparison.Ordinal); Assert.DoesNotContain("user-1", json, StringComparison.Ordinal); Assert.DoesNotContain("collab-1", json, StringComparison.Ordinal); } // --------------------------- E6 : ServerInstance Worker -------------------- [Fact] public async Task E6_ServerInstance_ComesFromProvider_NotOtherComponents() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider(serverInstance: "WKR-SRV-01")); await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create)); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); Assert.Equal("WKR-SRV-01", call.Context!.ServerInstance); Assert.NotEqual("API-SRV-01", call.Context.ServerInstance); } // --------------------------- E7 : Environment Worker ----------------------- [Fact] public async Task E7_Environment_ComesFromProvider() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider(environment: "Staging")); await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create)); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); Assert.Equal("Staging", call.Context!.Environment); } // --------------------------- E8 : corrélation ------------------------------ [Fact] public async Task E8_OperationIdPreserved_NoFakeTraceIdInvented() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Create); await processor.ProcessOperationAsync(envelope); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); // Corrélation réelle : OperationId transporté de bout en bout (mobile → API // → TOSRV → Worker). Aucun TraceId n'est transporté (SyncEnvelope n'en porte // pas) : on n'en invente PAS un faux. Assert.Equal(envelope.OperationId, evt.OperationId); Assert.Null(evt.TraceId); Assert.Null(evt.SessionId); } // --------------------------- E9 : concurrence ------------------------------ [Fact] public async Task E9_ConcurrentOperations_DoNotContaminateCorrelation() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelopeA = CreateEnvelope(SyncOperationType.Create); var envelopeB = CreateEnvelope(SyncOperationType.Update); var envelopeC = CreateEnvelope(SyncOperationType.Delete); await Task.WhenAll( processor.ProcessOperationAsync(envelopeA), processor.ProcessOperationAsync(envelopeB), processor.ProcessOperationAsync(envelopeC)); Assert.Equal(3, store.Calls.Count); var operationIds = store.Calls.Select(c => Assert.Single(c.Batch.Events).OperationId).ToArray(); Assert.Contains(envelopeA.OperationId, operationIds); Assert.Contains(envelopeB.OperationId, operationIds); Assert.Contains(envelopeC.OperationId, operationIds); Assert.Equal(3, operationIds.Distinct().Count()); // Le contexte de déploiement est commun (instance = 1 client) : valeurs stables. Assert.All(store.Calls, c => Assert.Equal("WKR-SRV-01", c.Context!.ServerInstance)); Assert.All(store.Calls, c => Assert.Equal("Production", c.Context!.Environment)); } // --------------------------- E10 : absence de secrets ---------------------- [Fact] public async Task E10_NoSecrets_LeakIntoProducedEvents() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); // Payload métier volontairement truffé de valeurs sensibles : il ne doit // JAMAIS être copié dans l'événement Diagnostics produit. string payload = """ {"password":"S3CR3T","token":"Bearer eyJhbGciOiJIUzI1NiJ9","hmacKey":"h0000","connectionString":"Server=x;Password=y","CL_CleAPIInstall":"CLE-SECRET"} """; await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create, payload: payload)); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); string json = JsonSerializer.Serialize(call.Batch); string contextJson = JsonSerializer.Serialize(call.Context); Assert.DoesNotContain("S3CR3T", json, StringComparison.Ordinal); Assert.DoesNotContain("Bearer", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("eyJhbGciOi", json, StringComparison.Ordinal); Assert.DoesNotContain("hmacKey", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("connectionString", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("CL_CleAPIInstall", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("CLE-SECRET", json, StringComparison.Ordinal); Assert.DoesNotContain("CLE-SECRET", contextJson, StringComparison.Ordinal); } // --------------------------- E11 : AppVersion ------------------------------ [Fact] public async Task E11_AppVersion_UsesExistingConvention_NotHardcoded() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.Success()); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create)); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); // Convention Diagnostics existante : ApplicationConstants.Version (source de // vérité unique de la solution), jamais une chaîne "1.0.0.0" en dur ici. Assert.Equal(ApplicationConstants.Version, evt.AppVersion); } // --------------------------- Retry : échec intermédiaire → succès final ---- [Fact] public async Task Retry_IntermediateFailure_ThenSuccess_EmitsTransientWarningThenSuccess() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Create); // Tentative 1 : échec temporaire → retry, UN événement Warning/P2 (retry visible). bool first = await processor.ProcessOperationAsync(envelope); Assert.False(first); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) transientCall = Assert.Single(store.Calls); DiagnosticEventDto transientEvt = Assert.Single(transientCall.Batch.Events); Assert.Equal("OMK-WKR-SYNC-002", transientEvt.EventCode); Assert.Equal("Warning", transientEvt.Level); Assert.Equal("P2", transientEvt.Priority); Assert.False(transientEvt.Success); Assert.Equal(envelope.OperationId, transientEvt.OperationId); // OperationId stable. // Tentative 2 : succès → un DEUXIÈME événement 001 (Info/P3). handler.Result = SyncOperationResult.Success(); var envelopeRetry = envelope with { RetryCount = 1 }; bool second = await processor.ProcessOperationAsync(envelopeRetry); Assert.True(second); Assert.Equal(2, store.Calls.Count); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) successCall = store.Calls[1]; DiagnosticEventDto successEvt = Assert.Single(successCall.Batch.Events); Assert.Equal("OMK-WKR-SYNC-001", successEvt.EventCode); Assert.Equal("Info", successEvt.Level); Assert.Equal("P3", successEvt.Priority); Assert.Equal(envelope.OperationId, successEvt.OperationId); // OperationId stable. } // --------------------------- Retry : échec définitif après max ------------- [Fact] public async Task Retry_MaxRetry_EmitsSingleFailedEvent() { var repo = new FakeSyncRepository(); var handler = new FakeHandler(SyncOperationResult.TransientFailure("DB_BUSY")); var options = DefaultOptions(); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, options: options, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Delete, retryCount: options.MaxRetryCount); bool handled = await processor.ProcessOperationAsync(envelope); Assert.True(handled); Assert.Contains(envelope.OperationId, repo.Rejected); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode); Assert.Equal("MAX_RETRY", evt.ErrorCode); Assert.Equal(envelope.OperationId, evt.OperationId); } // --------------------------- E12 : exception transitoire (détail) ---------- [Fact] public async Task E12_TransientException_EmitsWarning_WithExceptionTypeAndStackTrace() { var repo = new FakeSyncRepository(); var handler = new ThrowingHandler(new InvalidOperationException("DB indisponible (simulé)")); var store = new FakeDiagnosticStore(); var processor = CreateProcessor(repo, handler, store: store, provider: Provider()); var envelope = CreateEnvelope(SyncOperationType.Create); bool handled = await processor.ProcessOperationAsync(envelope); Assert.False(handled); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls); DiagnosticEventDto evt = Assert.Single(call.Batch.Events); Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode); Assert.Equal("Warning", evt.Level); Assert.Equal("P2", evt.Priority); Assert.False(evt.Success); Assert.Equal("UNEXPECTED", evt.ErrorCode); Assert.Equal("System.InvalidOperationException", evt.ExceptionType); Assert.NotNull(evt.StackTrace); Assert.Contains("DB indisponible", evt.Message, StringComparison.Ordinal); Assert.Equal(envelope.OperationId, evt.OperationId); } // ===== Fakes =============================================================== private sealed class FakeDiagnosticStore : IRemoteDiagnosticIngestionStore { private readonly object _gate = new(); private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = []; private readonly bool _throwOnStore; public FakeDiagnosticStore(bool throwOnStore = false) => _throwOnStore = throwOnStore; public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls { get { lock (_gate) { return _calls.ToArray(); } } } public Task StoreAsync( DiagnosticBatchDto batch, DiagnosticActorKey actorKey, string ingestionTraceId, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken = default, DiagnosticDeploymentContext? deploymentContext = null, DiagnosticDeviceKey? deviceKey = null, string? licenseNumber = null) { if (_throwOnStore) { throw new InvalidOperationException("Store en panne (simulé)."); } lock (_gate) { _calls.Add((batch, deploymentContext)); } return Task.FromResult(batch.Events.Count); } } private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider { public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty; public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(Context); } private sealed class FakeSyncRepository : ISyncTransferRepository { private readonly object _gate = new(); private readonly List _succeeded = []; private readonly List _rejected = []; public IReadOnlyList Succeeded { get { lock (_gate) { return _succeeded.ToArray(); } } } public IReadOnlyList Rejected { get { lock (_gate) { return _rejected.ToArray(); } } } public Task ExistsAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(Succeeded.Contains(operationId) || Rejected.Contains(operationId)); public Task GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(SyncOperationState.Unknown); public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task GetStatusAsync(Guid operationId, string deviceId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetTerminalResultAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task PurgeLogsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default) => Task.FromResult(0); public Task TransferToReceptionAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task> GetPendingOperationsAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) { lock (_gate) { _succeeded.Add(entry.OperationId); } return Task.CompletedTask; } public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) { lock (_gate) { _rejected.Add(entry.OperationId); } return Task.CompletedTask; } public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeHandler : ISyncOperationHandler { public FakeHandler(SyncOperationResult result) => Result = result; public SyncOperationResult Result { get; set; } public IReadOnlyCollection SupportedEntityTypes => ["Client"]; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) => Task.FromResult(Result); } private sealed class ThrowingHandler : ISyncOperationHandler { private readonly Exception _exception; public ThrowingHandler(Exception exception) => _exception = exception; public IReadOnlyCollection SupportedEntityTypes => ["Client"]; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) => throw _exception; } private sealed class FakeTransaction : IWebServiceTransactionContext { public IHfsqlWriteConnection Connection => throw new NotSupportedException("Le fake de transaction n'expose pas de connexion physique."); public Task BeginAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RollbackAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } }