748 lines
29 KiB
C#
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);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|