Files
2026-10-23 18:46:50 +02:00

378 lines
14 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<SyncProcessor>.Instance,
new FakeWebServiceTransactionContext(),
diagnosticEmitter);
var services = new ServiceCollection();
services.AddSingleton(processor);
services.AddSingleton<ISyncTransferRepository>(repo);
var provider = services.BuildServiceProvider();
return new SyncWorker(
scopeFactory ?? new FakeScopeFactory(provider),
options ?? DefaultOptions(),
NullLogger<SyncWorker>.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<bool> ExistsAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<SyncOperationState> GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(SyncOperationState.Unknown);
public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<SyncStatusResponse?> GetStatusAsync(Guid operationId, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncStatusResponse?>(null);
public Task<SyncLogEntry?> GetTerminalResultAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncLogEntry?>(null);
public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<int> PurgeLogsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> TransferToReceptionAsync(CancellationToken cancellationToken = default)
{
TransferCalled = true;
return Task.FromResult(0);
}
public Task<IReadOnlyList<SyncEnvelope>> GetPendingOperationsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SyncEnvelope>>(Array.Empty<SyncEnvelope>());
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<bool> ExistsAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<SyncOperationState> GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(SyncOperationState.Unknown);
public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<SyncStatusResponse?> GetStatusAsync(Guid operationId, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncStatusResponse?>(null);
public Task<SyncLogEntry?> GetTerminalResultAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncLogEntry?>(null);
public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<int> PurgeLogsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> TransferToReceptionAsync(CancellationToken cancellationToken = default)
{
Attempts++;
throw new InvalidOperationException("Erreur transitoire simulée.");
}
public Task<IReadOnlyList<SyncEnvelope>> GetPendingOperationsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SyncEnvelope>>(Array.Empty<SyncEnvelope>());
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<string> SupportedEntityTypes => new[] { "Client" };
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
=> Task.FromResult(_result);
}
/// <summary>
/// Fake de session transactionnelle BATIK_TEST : no-op pour les tests du
/// worker (l'orchestration transactionnelle est testée dans SyncProcessorTests).
/// </summary>
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;
}
/// <summary>Store Diagnostics en mémoire : enregistre les lots émis (vérifie l'émission de cycle).</summary>
private sealed class RecordingDiagnosticStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<DiagnosticBatchDto> _batches = [];
public IReadOnlyList<DiagnosticBatchDto> Batches
{
get { lock (_gate) { return _batches.ToArray(); } }
}
public Task<int> 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);
}
}
/// <summary>Provider de contexte de déploiement vide (fail-open).</summary>
private sealed class EmptyDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(DiagnosticDeploymentContext.Empty);
}
}