using Microsoft.Extensions.DependencyInjection; 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 BackgroundService de synchronisation serveur (SyncWorker). /// Vérifie le comportement d'orchestration : boucle, délai, CancellationToken, /// gestion des exceptions de cycle, sans nécessiter de base HFSQL réelle. /// public class SyncWorkerTests { private static SyncOptions DefaultOptions() => new() { MaxRetryCount = 30, PollingIntervalSeconds = 1, RetryDelaySeconds = 1, LogRetentionDays = 30 }; private static SyncWorker CreateWorker( ISyncTransferRepository repo, SyncOptions? options = null, IServiceScopeFactory? scopeFactory = null, RecordingDiagnosticStore? store = null) { var diagnosticEmitter = new WorkerDiagnosticEmitter( store ?? new RecordingDiagnosticStore(), new EmptyDeploymentContextProvider()); var processor = new SyncProcessor( repo, new SyncOperationHandlerRegistry(new[] { new FakeSyncOperationHandler(SyncOperationResult.Success()) }), new DefaultSyncOperationHandler(), options ?? DefaultOptions(), NullLogger.Instance, new FakeWebServiceTransactionContext(), diagnosticEmitter); var services = new ServiceCollection(); services.AddSingleton(processor); services.AddSingleton(repo); var provider = services.BuildServiceProvider(); return new SyncWorker( scopeFactory ?? new FakeScopeFactory(provider), options ?? DefaultOptions(), NullLogger.Instance, diagnosticEmitter); } [Fact] public async Task Worker_Calls_SyncProcessor_And_Stops_On_Cancellation() { // Arrange var repo = new FakeSyncTransferRepository(); var worker = CreateWorker(repo); using var cts = new CancellationTokenSource(); // Act : démarre le worker, laisse tourner quelques cycles, puis arrête. Task runTask = worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : le transfert TOSRV → MobReception a été appelé (SyncProcessor exécuté). Assert.True(repo.TransferCalled); } [Fact] public async Task Worker_With_No_Work_Does_Not_Spin_Cpu() { // Arrange var repo = new FakeSyncTransferRepository(); var worker = CreateWorker(repo); using var cts = new CancellationTokenSource(); // Act : démarre, laisse tourner, arrête. Task runTask = worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : le worker a bien attendu entre les cycles (pas de boucle CPU). // Le nombre de cycles est borné par le délai (RetryDelaySeconds = 1s). Assert.True(repo.TransferCalled); } [Fact] public async Task Worker_Continues_After_Cycle_Exception() { // Arrange : un repository qui lève une exception au premier cycle. var repo = new ThrowingSyncTransferRepository(); var worker = CreateWorker(repo); using var cts = new CancellationTokenSource(); // Act : démarre, laisse tourner, arrête. Task runTask = worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : le worker n'a pas été tué par l'exception du cycle. Assert.True(repo.Attempts >= 1); } [Fact] public async Task Worker_CycleError_EmitsDiagnosticEvent_WithExceptionDetail() { // Arrange : un repository qui lève au premier cycle + un store qui enregistre. var repo = new ThrowingSyncTransferRepository(); var store = new RecordingDiagnosticStore(); var worker = CreateWorker(repo, store: store); using var cts = new CancellationTokenSource(); // Act : un cycle échoue → l'erreur est émise dans le JSONL (fail-open). Task runTask = worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : au moins un événement de cycle en erreur, avec le détail d'exception. Assert.True(repo.Attempts >= 1); Assert.NotEmpty(store.Batches); DiagnosticEventDto evt = Assert.Single(store.Batches[0].Events); Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode); Assert.Equal("Error", evt.Level); Assert.Equal("P1", evt.Priority); Assert.Equal("CYCLE_ERROR", evt.ErrorCode); Assert.Equal("System.InvalidOperationException", evt.ExceptionType); Assert.NotNull(evt.StackTrace); } [Fact] public async Task Worker_Respects_PollInterval_Configuration() { // Arrange : intervalle de polling très court pour observer plusieurs cycles. var options = DefaultOptions(); options.PollingIntervalSeconds = 1; var repo = new FakeSyncTransferRepository(); var worker = CreateWorker(repo, options); using var cts = new CancellationTokenSource(); // Act Task runTask = worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : le worker a exécuté au moins un cycle (le délai est appliqué). Assert.True(repo.TransferCalled); } [Fact] public async Task Worker_Stops_Cleanly_On_Cancellation() { // Arrange var repo = new FakeSyncTransferRepository(); var worker = CreateWorker(repo); using var cts = new CancellationTokenSource(); // Act : démarre puis arrête immédiatement. Task runTask = worker.StartAsync(cts.Token); cts.Cancel(); await worker.StopAsync(CancellationToken.None); await runTask; // Assert : l'arrêt est propre (aucune exception). Assert.True(runTask.IsCompletedSuccessfully); } // ===== Fakes ===== private sealed class FakeScopeFactory : IServiceScopeFactory { private readonly ServiceProvider _provider; public FakeScopeFactory(ServiceProvider provider) { _provider = provider; } public IServiceScope CreateScope() => new FakeScope(_provider); } private sealed class FakeScope : IServiceScope { private readonly ServiceProvider _provider; public FakeScope(ServiceProvider provider) { _provider = provider; } public IServiceProvider ServiceProvider => _provider; public void Dispose() { } } private sealed class FakeSyncTransferRepository : ISyncTransferRepository { public bool TransferCalled { get; private set; } public Task ExistsAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(false); 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) { TransferCalled = true; return Task.FromResult(0); } public Task> GetPendingOperationsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Array.Empty()); public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class ThrowingSyncTransferRepository : ISyncTransferRepository { public int Attempts { get; private set; } public Task ExistsAsync(Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult(false); 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) { Attempts++; throw new InvalidOperationException("Erreur transitoire simulée."); } public Task> GetPendingOperationsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Array.Empty()); public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeSyncOperationHandler : ISyncOperationHandler { private readonly SyncOperationResult _result; public FakeSyncOperationHandler(SyncOperationResult result) { _result = result; } public IReadOnlyCollection SupportedEntityTypes => new[] { "Client" }; public Task ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default) => Task.FromResult(_result); } /// /// Fake de session transactionnelle BATIK_TEST : no-op pour les tests du /// worker (l'orchestration transactionnelle est testée dans SyncProcessorTests). /// private sealed class FakeWebServiceTransactionContext : 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; } /// Store Diagnostics en mémoire : enregistre les lots émis (vérifie l'émission de cycle). private sealed class RecordingDiagnosticStore : IRemoteDiagnosticIngestionStore { private readonly object _gate = new(); private readonly List _batches = []; public IReadOnlyList Batches { get { lock (_gate) { return _batches.ToArray(); } } } public Task StoreAsync( DiagnosticBatchDto batch, DiagnosticActorKey actorKey, string ingestionTraceId, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken = default, DiagnosticDeploymentContext? deploymentContext = null, DiagnosticDeviceKey? deviceKey = null, string? licenseNumber = null) { lock (_gate) { _batches.Add(batch); } return 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); } }