Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user