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

748 lines
29 KiB
C#

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 traitement des opérations de synchronisation serveur (SyncProcessor).
/// </summary>
public class SyncProcessorTests
{
private static SyncOptions DefaultOptions() => new()
{
MaxRetryCount = 30,
RetryDelaySeconds = 120,
LogRetentionDays = 30
};
private static SyncEnvelope CreateEnvelope(
SyncOperationType type,
Guid? operationId = null,
int retryCount = 0,
string deviceId = "device-1")
{
return new SyncEnvelope
{
OperationId = operationId ?? Guid.NewGuid(),
DeviceId = deviceId,
EntityType = "Client",
OperationType = type,
EntityGuid = Guid.NewGuid(),
RetryCount = retryCount,
Payload = "{}",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
}
private static SyncProcessor CreateProcessor(
FakeSyncTransferRepository repo,
ISyncOperationHandler handler,
SyncOptions? options = null,
IWebServiceTransactionContext? transactionContext = null,
IRemoteDiagnosticIngestionStore? store = null,
IDiagnosticDeploymentContextProvider? deploymentContextProvider = null)
{
var registry = new SyncOperationHandlerRegistry(new[] { handler });
return new SyncProcessor(
repo,
registry,
new DefaultSyncOperationHandler(),
options ?? DefaultOptions(),
NullLogger<SyncProcessor>.Instance,
transactionContext ?? new FakeWebServiceTransactionContext(),
new WorkerDiagnosticEmitter(
store ?? new NullDiagnosticStore(),
deploymentContextProvider ?? new EmptyDeploymentContextProvider()));
}
[Fact]
public async Task Success_On_First_Pass_Marks_Success_And_Logs_Success()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.Success());
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Act
bool handled = await processor.ProcessOperationAsync(env);
// Assert
Assert.True(handled);
Assert.Contains(env.OperationId, repo.Succeeded);
Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId);
}
[Fact]
public async Task Temporary_Error_Increments_RetryCount()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Update);
// Act
bool handled = await processor.ProcessOperationAsync(env);
// Assert
Assert.False(handled);
Assert.Equal(1, repo.RetryCounts[env.OperationId]);
}
[Fact]
public async Task Success_After_Retry_Is_Processed()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier passage : erreur temporaire.
await processor.ProcessOperationAsync(env);
// Le handler réussit au second passage.
handler.Result = SyncOperationResult.Success();
var envRetry = env with { RetryCount = 1 };
// Act
bool handled = await processor.ProcessOperationAsync(envRetry);
// Assert
Assert.True(handled);
Assert.Contains(env.OperationId, repo.Succeeded);
Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId);
}
[Fact]
public async Task Rejected_After_Max_Retry_Count()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var options = DefaultOptions();
var processor = CreateProcessor(repo, handler, options);
var env = CreateEnvelope(SyncOperationType.Delete, retryCount: options.MaxRetryCount);
// Act
bool handled = await processor.ProcessOperationAsync(env);
// Assert
Assert.True(handled);
Assert.Contains(env.OperationId, repo.Rejected);
Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Rejected && l.OperationId == env.OperationId);
}
[Fact]
public async Task Permanent_Failure_Is_Rejected_Immediately()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.PermanentFailure("NO_HANDLER"));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Act
bool handled = await processor.ProcessOperationAsync(env);
// Assert : un échec définitif ne consomme pas 30 retries.
Assert.True(handled);
Assert.Contains(env.OperationId, repo.Rejected);
Assert.False(repo.RetryCounts.ContainsKey(env.OperationId));
Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Rejected && l.OperationId == env.OperationId);
}
[Fact]
public async Task Create_Is_Processed_Before_Update()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.Success());
var processor = CreateProcessor(repo, handler);
var updateEnv = CreateEnvelope(SyncOperationType.Update);
var createEnv = CreateEnvelope(SyncOperationType.Create);
repo.Pending.Add(updateEnv);
repo.Pending.Add(createEnv);
// Act
await processor.ProcessAsync();
// Assert : CREATE traité avant UPDATE.
int createIndex = repo.ProcessedOrder.IndexOf(createEnv.OperationId);
int updateIndex = repo.ProcessedOrder.IndexOf(updateEnv.OperationId);
Assert.True(createIndex >= 0);
Assert.True(updateIndex >= 0);
Assert.True(createIndex < updateIndex);
}
[Fact]
public async Task Update_Is_Processed_Before_Delete()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.Success());
var processor = CreateProcessor(repo, handler);
var deleteEnv = CreateEnvelope(SyncOperationType.Delete);
var updateEnv = CreateEnvelope(SyncOperationType.Update);
repo.Pending.Add(deleteEnv);
repo.Pending.Add(updateEnv);
// Act
await processor.ProcessAsync();
// Assert : UPDATE traité avant DELETE.
int updateIndex = repo.ProcessedOrder.IndexOf(updateEnv.OperationId);
int deleteIndex = repo.ProcessedOrder.IndexOf(deleteEnv.OperationId);
Assert.True(updateIndex >= 0);
Assert.True(deleteIndex >= 0);
Assert.True(updateIndex < deleteIndex);
}
[Fact]
public async Task OperationId_Is_Preserved_Through_Retry()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Act
await processor.ProcessOperationAsync(env);
// Assert : OperationId inchangé pendant le retry.
Assert.Equal(env.OperationId, repo.RetryCounts.Keys.Single());
}
[Fact]
public async Task Transfer_Is_Called_Before_Processing()
{
// Arrange
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.Success());
var processor = CreateProcessor(repo, handler);
// Act
await processor.ProcessAsync();
// Assert : le transfert TOSRV → MobReception a été appelé.
Assert.True(repo.TransferCalled);
}
[Fact]
public async Task Handler_Receives_Common_Contract_Regardless_Of_Generation()
{
// Arrange : le handler public ne voit jamais de différence Batik/Omika.
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.Success());
var processor = CreateProcessor(repo, handler);
// Deux enveloppes identiques au niveau du contrat commun, quelle que soit
// la génération de base (Batik ou Omika). Le handler ne reçoit que SyncOperation.
var env = CreateEnvelope(SyncOperationType.Create);
// Act
await processor.ProcessOperationAsync(env);
// Assert : le handler a reçu un contrat commun SyncOperation.
Assert.NotNull(handler.LastOperation);
Assert.Equal(env.OperationId, handler.LastOperation.OperationId);
Assert.Equal(env.EntityType, handler.LastOperation.EntityType);
Assert.Equal(env.OperationType, handler.LastOperation.OperationType);
Assert.Equal(env.EntityGuid, handler.LastOperation.EntityGuid);
Assert.Equal(env.DeviceId, handler.LastOperation.DeviceId);
}
[Fact]
public async Task Duplicate_OperationId_Does_Not_Reapply_Handler()
{
// Arrange : le handler réussit et enregistre un CanonicalEntityGuid.
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(
SyncOperationResult.Success("canonical-1", Guid.NewGuid(), Guid.NewGuid()));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier traitement : application métier réelle.
await processor.ProcessOperationAsync(env);
int handlerCallsAfterFirst = handler.CallCount;
// Act : le même OperationId est présenté à nouveau (ex. doublon MobReception).
bool handled = await processor.ProcessOperationAsync(env);
// Assert : l'opération est considérée comme traitée, mais le handler n'est
// PAS rappelé (aucune seconde création métier).
Assert.True(handled);
Assert.Equal(handlerCallsAfterFirst, handler.CallCount);
Assert.Contains(env.OperationId, repo.Succeeded);
}
[Fact]
public async Task Duplicate_OperationId_Rejected_Is_Not_Reapplied()
{
// Arrange : le handler échoue définitivement → REJECTED.
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(SyncOperationResult.PermanentFailure("NO_HANDLER"));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier traitement : rejet définitif.
await processor.ProcessOperationAsync(env);
int handlerCallsAfterFirst = handler.CallCount;
// Act : le même OperationId est présenté à nouveau.
bool handled = await processor.ProcessOperationAsync(env);
// Assert : l'opération reste rejetée, le handler n'est pas rappelé.
Assert.True(handled);
Assert.Equal(handlerCallsAfterFirst, handler.CallCount);
Assert.Contains(env.OperationId, repo.Rejected);
}
[Fact]
public async Task Duplicate_OperationId_Preserves_CanonicalEntityGuid()
{
// Arrange : le handler réussit avec un CanonicalEntityGuid.
var requested = Guid.NewGuid();
var canonical = Guid.NewGuid();
var repo = new FakeSyncTransferRepository();
var handler = new FakeSyncOperationHandler(
SyncOperationResult.Success("canonical-1", requested, canonical));
var processor = CreateProcessor(repo, handler);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier traitement.
await processor.ProcessOperationAsync(env);
// Act : le même OperationId est présenté à nouveau.
await processor.ProcessOperationAsync(env);
// Assert : le CanonicalEntityGuid d'origine est conservé dans le log
// terminal reconstitué (idempotence par OperationId).
SyncLogEntry? terminal = repo.Logs
.Where(l => l.OperationId == env.OperationId && l.Status == SyncStatus.Success)
.LastOrDefault();
Assert.NotNull(terminal);
Assert.Equal(canonical, terminal.CanonicalEntityGuid);
Assert.Equal(requested, terminal.RequestedEntityGuid);
Assert.Equal("canonical-1", terminal.CreatedEntityId);
}
// ===== Tests transactionnels (idempotence retry) =====
[Fact]
public async Task Rollback_On_Exception_Before_MobLog_Leaves_No_Durable_Business()
{
// Arrange : un handler qui écrit une Intervention dans la transaction, puis
// lève une exception avant que MobLog SUCCESS ne soit écrit.
var repo = new FakeSyncTransferRepository();
var tx = new FakeTransactionalWebServiceTransactionContext();
var handler = new ThrowingAfterWriteHandler(tx);
var processor = CreateProcessor(repo, handler, transactionContext: tx);
var env = CreateEnvelope(SyncOperationType.Create);
// Act : le handler écrit l'Intervention puis lève une exception retryable.
bool handled = await processor.ProcessOperationAsync(env);
// Assert : ROLLBACK → aucune Intervention durable, aucun MobLog SUCCESS.
Assert.False(handled);
Assert.Equal(0, tx.CommittedInterventions.Count);
Assert.Equal(0, repo.Succeeded.Count);
Assert.DoesNotContain(repo.Logs, l => l.Status == SyncStatus.Success);
Assert.Equal(1, tx.RollbackCount);
}
[Fact]
public async Task Retry_After_Rollback_Commits_Business_And_MobLog()
{
// Arrange : premier passage → exception (rollback), second passage → succès.
var repo = new FakeSyncTransferRepository();
var tx = new FakeTransactionalWebServiceTransactionContext();
var handler = new ThrowingThenSuccessHandler(tx);
var processor = CreateProcessor(repo, handler, transactionContext: tx);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier passage : exception → rollback, aucune écriture durable.
bool firstHandled = await processor.ProcessOperationAsync(env);
Assert.False(firstHandled);
Assert.Equal(0, tx.CommittedInterventions.Count);
Assert.Equal(0, repo.Succeeded.Count);
// Act : retry → le handler réussit → COMMIT atomique (Intervention + MobLog).
var envRetry = env with { RetryCount = 1 };
bool secondHandled = await processor.ProcessOperationAsync(envRetry);
// Assert : Intervention = 1, MobLog SUCCESS = 1.
Assert.True(secondHandled);
Assert.Equal(1, tx.CommittedInterventions.Count);
Assert.Equal(1, repo.Succeeded.Count);
Assert.Contains(repo.Logs, l => l.Status == SyncStatus.Success && l.OperationId == env.OperationId);
}
[Fact]
public async Task Crash_After_Commit_Does_Not_Reapply_Handler_On_Retry()
{
// Arrange : premier passage → succès complet (Intervention + MobLog + COMMIT).
var repo = new FakeSyncTransferRepository();
var tx = new FakeTransactionalWebServiceTransactionContext();
var handler = new SuccessWritingHandler(tx);
var processor = CreateProcessor(repo, handler, transactionContext: tx);
var env = CreateEnvelope(SyncOperationType.Create);
// Premier traitement : COMMIT réussi (Intervention + MobLog SUCCESS durables).
bool firstHandled = await processor.ProcessOperationAsync(env);
Assert.True(firstHandled);
Assert.Equal(1, tx.CommittedInterventions.Count);
Assert.Equal(1, repo.Succeeded.Count);
int handlerCallsAfterFirst = handler.CallCount;
// Act : simulation crash après COMMIT → le même OperationId est rejoué.
bool replayedHandled = await processor.ProcessOperationAsync(env);
// Assert : GetTerminalResultAsync → SUCCESS → handler NON rappelé,
// aucune Intervention supplémentaire. Le log terminal SUCCESS est
// reconstitué (re-marquage) sans réapplication métier : l'OperationId
// reste SUCCESS, mais le handler n'est pas rappelé et aucune nouvelle
// Intervention n'est durable.
Assert.True(replayedHandled);
Assert.Equal(handlerCallsAfterFirst, handler.CallCount);
Assert.Equal(1, tx.CommittedInterventions.Count);
Assert.Contains(env.OperationId, repo.Succeeded);
}
[Fact]
public async Task Two_Different_OperationIds_Produce_Two_Business_Creations()
{
// Arrange : deux OperationId réellement différents A et B.
var repo = new FakeSyncTransferRepository();
var tx = new FakeTransactionalWebServiceTransactionContext();
var handler = new SuccessWritingHandler(tx);
var processor = CreateProcessor(repo, handler, transactionContext: tx);
var envA = CreateEnvelope(SyncOperationType.Create);
var envB = CreateEnvelope(SyncOperationType.Create);
// Act : deux opérations distinctes.
bool handledA = await processor.ProcessOperationAsync(envA);
bool handledB = await processor.ProcessOperationAsync(envB);
// Assert : 2 créations métier durables, 2 MobLog SUCCESS.
Assert.True(handledA);
Assert.True(handledB);
Assert.Equal(2, tx.CommittedInterventions.Count);
Assert.Equal(2, repo.Succeeded.Count);
Assert.Contains(envA.OperationId, repo.Succeeded);
Assert.Contains(envB.OperationId, repo.Succeeded);
}
// ===== Fakes =====
private sealed class FakeSyncTransferRepository : ISyncTransferRepository
{
public List<SyncEnvelope> Pending { get; } = new();
public List<Guid> Succeeded { get; } = new();
public List<Guid> Rejected { get; } = new();
public List<Guid> ProcessedOrder { get; } = new();
public List<SyncLogEntry> Logs { get; } = new();
public Dictionary<Guid, int> RetryCounts { get; } = new();
public bool TransferCalled { get; private set; }
public Task<bool> ExistsAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(Succeeded.Contains(operationId) || Rejected.Contains(operationId));
public Task<SyncOperationState> GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default)
{
if (Succeeded.Contains(operationId))
{
return Task.FromResult(SyncOperationState.Success);
}
if (Rejected.Contains(operationId))
{
return Task.FromResult(SyncOperationState.Rejected);
}
return Task.FromResult(SyncOperationState.Unknown);
}
public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default)
{
Pending.Add(envelope);
return 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)
{
// Retourne le dernier log terminal (SUCCESS / REJECTED) pour l'opération.
SyncLogEntry? terminal = Logs
.Where(l => l.OperationId == operationId &&
(l.Status == SyncStatus.Success || l.Status == SyncStatus.Rejected))
.LastOrDefault();
return Task.FromResult(terminal);
}
public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
{
Logs.Add(entry);
return 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>>(Pending.ToList());
public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
{
Succeeded.Add(entry.OperationId);
ProcessedOrder.Add(entry.OperationId);
Logs.Add(entry);
return Task.CompletedTask;
}
public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
{
Rejected.Add(entry.OperationId);
ProcessedOrder.Add(entry.OperationId);
Logs.Add(entry);
return Task.CompletedTask;
}
public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default)
{
RetryCounts[operationId] = newRetryCount;
return Task.CompletedTask;
}
}
private sealed class FakeSyncOperationHandler : ISyncOperationHandler
{
public SyncOperationResult Result { get; set; }
public SyncOperation? LastOperation { get; private set; }
public int CallCount { get; private set; }
public FakeSyncOperationHandler(SyncOperationResult result)
{
Result = result;
}
public IReadOnlyCollection<string> SupportedEntityTypes => new[] { "Client" };
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
{
LastOperation = operation;
CallCount++;
return Task.FromResult(Result);
}
}
/// <summary>
/// Fake de session transactionnelle BATIK_TEST : enregistre les appels
/// Begin / Commit / Rollback pour vérifier l'orchestration transactionnelle
/// du SyncProcessor sans dépendre d'une connexion OLE DB réelle.
/// </summary>
private sealed class FakeWebServiceTransactionContext : IWebServiceTransactionContext
{
public IHfsqlWriteConnection Connection => throw new NotSupportedException(
"Le fake de transaction n'expose pas de connexion physique.");
public int BeginCount { get; private set; }
public int CommitCount { get; private set; }
public int RollbackCount { get; private set; }
public Task BeginAsync(CancellationToken cancellationToken = default)
{
BeginCount++;
return Task.CompletedTask;
}
public Task CommitAsync(CancellationToken cancellationToken = default)
{
CommitCount++;
return Task.CompletedTask;
}
public Task RollbackAsync(CancellationToken cancellationToken = default)
{
RollbackCount++;
return Task.CompletedTask;
}
}
/// <summary>
/// Fake de session transactionnelle BATIK_TEST simulant une base transactionnelle :
/// les écritures métier (Intervention) sont accumulées en attente pendant la
/// transaction, puis validées (Commit) ou annulées (Rollback). Permet de vérifier
/// qu'aucune Intervention partielle n'est durable après un rollback.
/// </summary>
private sealed class FakeTransactionalWebServiceTransactionContext : IWebServiceTransactionContext
{
private readonly List<Guid> _pendingInterventions = new();
/// <summary>Interventions rendues durables par un COMMIT.</summary>
public List<Guid> CommittedInterventions { get; } = new();
public int BeginCount { get; private set; }
public int CommitCount { get; private set; }
public int RollbackCount { get; private set; }
public IHfsqlWriteConnection Connection => throw new NotSupportedException(
"Le fake de transaction n'expose pas de connexion physique.");
/// <summary>Enregistre une écriture métier Intervention dans la transaction en cours.</summary>
public void WriteIntervention(Guid operationId) => _pendingInterventions.Add(operationId);
public Task BeginAsync(CancellationToken cancellationToken = default)
{
BeginCount++;
return Task.CompletedTask;
}
public Task CommitAsync(CancellationToken cancellationToken = default)
{
CommitCount++;
CommittedInterventions.AddRange(_pendingInterventions);
_pendingInterventions.Clear();
return Task.CompletedTask;
}
public Task RollbackAsync(CancellationToken cancellationToken = default)
{
RollbackCount++;
_pendingInterventions.Clear();
return Task.CompletedTask;
}
}
/// <summary>
/// Handler qui écrit une Intervention dans la transaction puis lève une exception
/// (simule un crash avant l'écriture de MobLog SUCCESS).
/// </summary>
private sealed class ThrowingAfterWriteHandler : ISyncOperationHandler
{
private readonly FakeTransactionalWebServiceTransactionContext _tx;
public ThrowingAfterWriteHandler(FakeTransactionalWebServiceTransactionContext tx)
{
_tx = tx;
}
public IReadOnlyCollection<string> SupportedEntityTypes => new[] { "Client" };
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
{
// Écriture métier Intervention dans la transaction.
_tx.WriteIntervention(operation.OperationId);
// Puis exception retryable avant MobLog SUCCESS.
throw new InvalidOperationException("Crash simulé avant MobLog SUCCESS.");
}
}
/// <summary>
/// Handler qui écrit une Intervention et lève une exception au premier appel,
/// puis réussit au second appel (simule un retry après rollback).
/// </summary>
private sealed class ThrowingThenSuccessHandler : ISyncOperationHandler
{
private readonly FakeTransactionalWebServiceTransactionContext _tx;
private int _calls;
public ThrowingThenSuccessHandler(FakeTransactionalWebServiceTransactionContext tx)
{
_tx = tx;
}
public IReadOnlyCollection<string> SupportedEntityTypes => new[] { "Client" };
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
{
_calls++;
_tx.WriteIntervention(operation.OperationId);
if (_calls == 1)
{
throw new InvalidOperationException("Erreur transitoire simulée.");
}
return Task.FromResult(SyncOperationResult.Success());
}
}
/// <summary>
/// Handler qui écrit une Intervention dans la transaction puis retourne SUCCESS
/// (simule une création métier réussie, durable après COMMIT).
/// </summary>
private sealed class SuccessWritingHandler : ISyncOperationHandler
{
private readonly FakeTransactionalWebServiceTransactionContext _tx;
public SuccessWritingHandler(FakeTransactionalWebServiceTransactionContext tx)
{
_tx = tx;
}
public IReadOnlyCollection<string> SupportedEntityTypes => new[] { "Client" };
public int CallCount { get; private set; }
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
{
CallCount++;
// Écriture métier Intervention dans la transaction.
_tx.WriteIntervention(operation.OperationId);
return Task.FromResult(SyncOperationResult.Success());
}
}
/// <summary>Store Diagnostics no-op : les tests SyncProcessor historiques ne
/// vérifient pas l'émission d'événements (couvert par WorkerSyncDiagnosticsTests).</summary>
private sealed class NullDiagnosticStore : IRemoteDiagnosticIngestionStore
{
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
=> 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);
}
}