378 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
|