using Microsoft.Extensions.Logging.Abstractions; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Mobile.Snapshot; using Omika.Core.Contracts.Mobile.Sync; namespace Omika.Tests.Mobile.Local; /// /// Tests du SyncEngine : offline, envoi, statuts, réconciliation, snapshot, /// concurrence, CancellationToken, STATUS_NOT_AVAILABLE. /// public class SyncEngineTests : LocalTestBase { private sealed class FakeNetwork : INetworkConnectivity { public bool Online { get; set; } = true; public Task IsOnlineAsync(CancellationToken ct = default) => Task.FromResult(Online); } private sealed class FakeApiClient : IMobileApiClient { public int SendCalls { get; private set; } public int StatusCalls { get; private set; } public int SnapshotCalls { get; private set; } public SyncStatus StatusToReturn { get; set; } = SyncStatus.Success; public Guid? CanonicalGuidToReturn { get; set; } public bool ThrowOnSend { get; set; } public bool ThrowOnSnapshot { get; set; } public MobileSnapshotResponse? SnapshotToReturn { get; set; } public TimeSpan SendDelay { get; set; } = TimeSpan.Zero; public async Task SendOperationAsync(SyncOperation operation, CancellationToken ct = default) { SendCalls++; if (ThrowOnSend) { throw new HttpRequestException("Network error"); } if (SendDelay > TimeSpan.Zero) { await Task.Delay(SendDelay, ct); } return new SyncOperationReceipt { OperationId = operation.OperationId, Status = "RECEIVED", ReceivedAtUtc = DateTimeOffset.UtcNow }; } public Task GetStatusAsync(Guid operationId, CancellationToken ct = default) { StatusCalls++; return Task.FromResult(new SyncStatusResponse { OperationId = operationId, Status = StatusToReturn, CanonicalEntityGuid = CanonicalGuidToReturn, ErrorCode = StatusToReturn == SyncStatus.Rejected ? "ERR001" : null, ErrorMessage = StatusToReturn == SyncStatus.Rejected ? "Rejeté" : null }); } public Task GetSnapshotAsync(CancellationToken ct = default) { SnapshotCalls++; if (ThrowOnSnapshot) { throw new HttpRequestException("Snapshot error"); } return Task.FromResult(SnapshotToReturn ?? new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); } public Task DownloadDocumentAsync( Guid documentGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task UploadDocumentAsync( Omika.Core.Contracts.Mobile.Documents.DocumentUploadRequest request, CancellationToken ct = default) => throw new NotImplementedException(); public string? AccessToken { get; set; } public Task ActivateAsync( Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default) => throw new NotImplementedException(); public Task ValidateAsync( Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetBootstrapAsync(CancellationToken ct = default) => throw new NotImplementedException(); } private readonly FakeNetwork _network = new(); private readonly FakeApiClient _api = new(); private readonly MobileIdentityReconciliationService _reconciliation; private readonly SnapshotApplier _snapshotApplier; private readonly SyncEngine _engine; public SyncEngineTests() { _reconciliation = new MobileIdentityReconciliationService( Database, IdentityMappingRepository, OutboxRepository, []); _snapshotApplier = new SnapshotApplier(Database); _engine = new SyncEngine( _network, _api, OutboxRepository, _reconciliation, _snapshotApplier, new FakeDeviceIdProvider(), NullLogger.Instance); } private sealed class FakeDeviceIdProvider : IDeviceIdProvider { public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult("device-test-1"); } private static OutboxOperation CreateOp(SyncOperationType type, string entityType = "Client") => new() { OperationId = Guid.NewGuid(), EntityType = entityType, OperationType = type, RequestedEntityGuid = Guid.NewGuid(), Payload = "{}", State = OutboxState.Pending }; [Fact] public async Task Offline_AucuneTentativeHTTP() { // Arrange _network.Online = false; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert Assert.True(result.Executed); Assert.Equal(0, _api.SendCalls); Assert.Equal(0, _api.SnapshotCalls); Assert.Equal(0, result.OperationsSent); } [Fact] public async Task Online_OutboxVide() { // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert Assert.True(result.Executed); Assert.Equal(0, _api.SendCalls); Assert.Equal(0, result.OperationsSent); Assert.True(result.SnapshotApplied); } [Fact] public async Task EnvoiOperation() { // Arrange var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert Assert.Equal(1, _api.SendCalls); Assert.Equal(1, result.OperationsSent); Assert.Equal(1, result.OperationsSucceeded); } [Fact] public async Task StatusSuccess() { // Arrange _api.StatusToReturn = SyncStatus.Success; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act await _engine.SyncAsync(); // Assert OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Succeeded, loaded.State); } [Fact] public async Task StatusRejected() { // Arrange _api.StatusToReturn = SyncStatus.Rejected; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert Assert.Equal(1, result.OperationsRejected); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Rejected, loaded.State); Assert.Contains("ERR001", loaded.LastError); } [Fact] public async Task Reconciliation_CanonicalEntityGuid() { // Arrange Guid a = Guid.NewGuid(); Guid b = Guid.NewGuid(); _api.CanonicalGuidToReturn = b; var op = new OutboxOperation { OperationId = Guid.NewGuid(), EntityType = "Intervention", OperationType = SyncOperationType.Create, RequestedEntityGuid = a, Payload = $$"""{"Id":"{{a}}"}""", State = OutboxState.Pending }; await OutboxRepository.AddAsync(op); // Act await _engine.SyncAsync(); // Assert : le mapping A → B est enregistré. IdentityMapping? mapping = await IdentityMappingRepository.GetAsync("Intervention", a); Assert.NotNull(mapping); Assert.Equal(b, mapping.CanonicalEntityGuid); } [Fact] public async Task OperationDependante_DiffereeJusquaReconciliation() { // Arrange Guid interventionA = Guid.NewGuid(); Guid interventionB = Guid.NewGuid(); // Opération CREATE Intervention A. var createIntervention = new OutboxOperation { OperationId = Guid.NewGuid(), EntityType = "Intervention", OperationType = SyncOperationType.Create, RequestedEntityGuid = interventionA, Payload = $$"""{"Id":"{{interventionA}}"}""", State = OutboxState.Pending }; await OutboxRepository.AddAsync(createIntervention); // Opération CREATE RendezVous dépendant de l'Intervention A. var createRdv = new OutboxOperation { OperationId = Guid.NewGuid(), EntityType = "RendezVous", OperationType = SyncOperationType.Create, RequestedEntityGuid = Guid.NewGuid(), Payload = $$"""{"Id":"{{Guid.NewGuid()}}","InterventionId":"{{interventionA}}"}""", State = OutboxState.Pending, DependencyEntityType = "Intervention", DependencyRequestedEntityGuid = interventionA }; await OutboxRepository.AddAsync(createRdv); // Premier cycle : l'Intervention est envoyée, le RendezVous est différé. _api.CanonicalGuidToReturn = interventionB; await _engine.SyncAsync(); // Le RendezVous doit toujours être en Pending (différé). OutboxOperation? rdvLoaded = await OutboxRepository.GetByIdAsync(createRdv.OperationId); Assert.NotNull(rdvLoaded); Assert.Equal(OutboxState.Pending, rdvLoaded.State); // Second cycle : la dépendance est réconciliée, le RendezVous est envoyé. _api.CanonicalGuidToReturn = null; await _engine.SyncAsync(); rdvLoaded = await OutboxRepository.GetByIdAsync(createRdv.OperationId); Assert.NotNull(rdvLoaded); Assert.Equal(OutboxState.Succeeded, rdvLoaded.State); } [Fact] public async Task SnapshotAppliqueApresSucces() { // Arrange : une personne existante (table encore portée par le snapshot). var personne = new LocalPersonne { Id = Guid.NewGuid(), PayloadJson = """{"Id":"old"}""" }; await PersonneRepository.UpsertAsync(personne); _api.SnapshotToReturn = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new Omika.Core.Contracts.Clients.ContactDto { Id = Guid.NewGuid().ToString(), Nom = "Nouvelle personne" }] }; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert Assert.True(result.SnapshotApplied); Assert.Equal(1, await PersonneRepository.CountAsync()); LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First(); Assert.Contains("Nouvelle personne", loaded.PayloadJson); } [Fact] public async Task SnapshotEchoue_DonneesLocalesConservees() { // Arrange var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"old"}""" }; await ClientRepository.UpsertAsync(client); _api.ThrowOnSnapshot = true; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : le snapshot a échoué mais les données locales sont conservées. Assert.False(result.SnapshotApplied); Assert.Equal(1, await ClientRepository.CountAsync()); LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id); Assert.NotNull(loaded); Assert.Equal(client.Id, loaded.Id); } [Fact] public async Task SnapshotEchoue_GetSnapshotAsync_CauseConserveeDansResultat() { // Arrange : GetSnapshotAsync lève une exception. _api.ThrowOnSnapshot = true; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : SnapshotApplied=false et la cause est conservée dans Message. Assert.False(result.SnapshotApplied); Assert.NotNull(result.Message); Assert.Contains("Snapshot non appliqué", result.Message); // Message = texte UTILISATEUR : une indisponibilité réseau y est traduite en // libellé français stable, jamais le type ni le message technique (voir plus bas). Assert.Contains("Hors ligne", result.Message); // La cause structurée est conservée : étape download, type et message TECHNIQUE. Assert.Equal("download", result.FailureStage); Assert.Equal("HttpRequestException", result.ExceptionType); Assert.Equal("Snapshot error", result.ErrorMessage); } [Fact] public async Task SnapshotEchoue_ApplyAsync_CauseConserveeDansResultat() { // Arrange : GetSnapshotAsync réussit mais ApplyAsync lève (snapshot invalide). _api.SnapshotToReturn = new MobileSnapshotResponse { // GeneratedAtUtc non défini → la validation du SnapshotApplier lève // InvalidOperationException pendant ApplyAsync. Clients = [new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau client" }] }; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : SnapshotApplied=false et la cause est conservée dans Message. Assert.False(result.SnapshotApplied); Assert.NotNull(result.Message); Assert.Contains("Snapshot non appliqué", result.Message); // Le message d'une exception NON réseau passe tel quel dans le texte utilisateur ; // le NOM DU TYPE, lui, appartient au champ structuré ExceptionType (vérifié plus bas). Assert.Contains("GeneratedAtUtc", result.Message); // La cause structurée est conservée : étape apply, type et message. Assert.Equal("apply", result.FailureStage); Assert.Equal("InvalidOperationException", result.ExceptionType); Assert.Contains("GeneratedAtUtc", result.ErrorMessage); } [Fact] public async Task SnapshotSucces_GetSnapshotAsync_ApplyAsyncAppele() { // Arrange : un snapshot valide avec une personne. _api.SnapshotToReturn = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new Omika.Core.Contracts.Clients.ContactDto { Id = Guid.NewGuid().ToString(), Nom = "Personne appliquée" }] }; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : GetSnapshotAsync a été appelé, ApplyAsync a été appelé // (les données sont réellement en SQLite), SnapshotApplied=true. Assert.Equal(1, _api.SnapshotCalls); Assert.True(result.SnapshotApplied); Assert.Equal(1, await PersonneRepository.CountAsync()); LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First(); // Le Nom est stocké dans le PayloadJson. On désérialise pour vérifier la // valeur réelle. var stored = System.Text.Json.JsonSerializer.Deserialize(loaded.PayloadJson); Assert.NotNull(stored); Assert.Equal("Personne appliquée", stored.Nom); } [Fact] public async Task SnapshotInvalide_ValidationEchoue_DonneesLocalesConservees() { // Arrange : un client existant, puis un snapshot invalide (GeneratedAtUtc // manquant → la validation du SnapshotApplier lève InvalidOperationException). var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"old"}""" }; await ClientRepository.UpsertAsync(client); _api.SnapshotToReturn = new MobileSnapshotResponse { // GeneratedAtUtc non défini → validation échoue. Clients = [new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau client" }] }; // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : le snapshot invalide est rejeté, les données locales sont conservées. Assert.False(result.SnapshotApplied); Assert.Equal(1, await ClientRepository.CountAsync()); LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id); Assert.NotNull(loaded); Assert.Equal(client.Id, loaded.Id); } [Fact] public async Task DeuxSyncAsyncSimultanes_UnSeulMoteurActif() { // Arrange _api.SendDelay = TimeSpan.FromMilliseconds(200); var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act : lancer deux SyncAsync simultanés. Task t1 = _engine.SyncAsync(); Task t2 = _engine.SyncAsync(); SyncCycleResult[] results = await Task.WhenAll(t1, t2); // Assert : un seul a été exécuté, l'autre a été ignoré. Assert.Equal(1, results.Count(r => r.Executed)); Assert.Equal(1, results.Count(r => !r.Executed)); Assert.Equal(1, _api.SendCalls); } [Fact] public async Task CancellationToken_Annule() { // Arrange using var cts = new CancellationTokenSource(); cts.Cancel(); // Act / Assert : le token annulé est propagé. // TaskCanceledException hérite de OperationCanceledException. await Assert.ThrowsAnyAsync(() => _engine.SyncAsync(cts.Token)); } [Fact] public async Task StatusNotAvailable_JamaisConvertiEnSuccess_Reemission() { // Arrange _api.StatusToReturn = SyncStatus.StatusNotAvailable; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.SyncAsync(); // Assert : jamais converti en succès ; l'opération est ré-émise (Pending) // plutôt qu'abandonnée définitivement, afin de garantir la convergence. Assert.Equal(0, result.OperationsSucceeded); Assert.Equal(0, result.OperationsAnomaly); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Pending, loaded.State); Assert.Equal(1, loaded.RetryCount); } [Fact] public async Task StatusNotAvailable_DevientAnomalieTerminale_ApresRetries() { // Arrange _api.StatusToReturn = SyncStatus.StatusNotAvailable; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act : 6 cycles (1 + MaxStatusNotAvailableRetries) pour dépasser le seuil. for (int i = 0; i < 6; i++) { await _engine.SyncAsync(); } // Assert : après le plafond de ré-émissions, l'opération devient une // anomalie terminale visible (jamais un faux succès). OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.StatusNotAvailable, loaded.State); Assert.Equal(6, loaded.RetryCount); } [Fact] public async Task SnapshotAnnule_DonneesLocalesConservees() { // Arrange : un client existant, puis un snapshot avec un token annulé. var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"old"}""" }; await ClientRepository.UpsertAsync(client); _api.SnapshotToReturn = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau client" }] }; using var cts = new CancellationTokenSource(); cts.Cancel(); // Act / Assert : l'annulation est propagée → les données locales sont // conservées (l'ancienne base reste utilisable). await Assert.ThrowsAnyAsync(() => _engine.SyncAsync(cts.Token)); Assert.Equal(1, await ClientRepository.CountAsync()); LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id); Assert.NotNull(loaded); Assert.Equal(client.Id, loaded.Id); } // --------------------------------------------------------------------- // Envoi seul (PushAsync) : utilisé par la surveillance de fond. Il envoie // les données saisies mais ne réalise JAMAIS de synchronisation globale : // aucun snapshot, aucune donnée locale remplacée (l'écran en cours et un // formulaire de saisie ouverts restent intacts). // --------------------------------------------------------------------- [Fact] public async Task PushAsync_EnvoieLesDonnees_SansAucunSnapshot() { // Arrange : un snapshot serveur disponible (qui ne doit pas être lu) et // une opération locale en attente d'envoi. _api.SnapshotToReturn = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new Omika.Core.Contracts.Clients.ContactDto { Id = Guid.NewGuid().ToString(), Nom = "Personne serveur" }] }; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act : envoi seul (surveillance de fond). SyncCycleResult result = await _engine.PushAsync(); // Assert : les données partent réellement… Assert.True(result.Executed); Assert.Equal(1, _api.SendCalls); Assert.Equal(1, result.OperationsSent); // …mais aucune synchronisation globale n'a lieu : ni téléchargement, ni // application de snapshot (SnapshotApplied=false n'est pas un échec ici). Assert.Equal(0, _api.SnapshotCalls); Assert.False(result.SnapshotApplied); Assert.Null(result.FailureStage); Assert.Equal(0, await PersonneRepository.CountAsync()); } [Fact] public async Task PushAsync_NeRemplaceJamaisLesDonneesLocales() { // Arrange : une donnée locale (ex. fiche en cours de consultation) et un // snapshot serveur qui la contredirait. var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Nom":"Local"}""" }; await ClientRepository.UpsertAsync(client); _api.SnapshotToReturn = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Serveur" }] }; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act await _engine.PushAsync(); // Assert : la donnée locale est intacte (aucun import de snapshot). Assert.Equal(0, _api.SnapshotCalls); Assert.Equal(1, await ClientRepository.CountAsync()); LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id); Assert.NotNull(loaded); Assert.Equal(client.Id, loaded.Id); } [Fact] public async Task PushAsync_InterrogeLesStatuts_EtReconcilieLIdentite() { // Arrange : le serveur attribue un GUID canonique. var canonical = Guid.NewGuid(); _api.CanonicalGuidToReturn = canonical; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.PushAsync(); // Assert : l'envoi est mené jusqu'au bout (statut + réconciliation), donc // la donnée locale est bien rattachée à son identité définitive. Assert.Equal(1, _api.StatusCalls); Assert.Equal(1, result.OperationsSucceeded); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Succeeded, loaded.State); Assert.Equal(canonical, loaded.CanonicalEntityGuid); // Toujours aucun snapshot : l'identité est réconciliée sans synchronisation globale. Assert.Equal(0, _api.SnapshotCalls); } [Fact] public async Task PushAsync_HorsLigne_AucuneTentativeHTTP() { // Arrange _network.Online = false; var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act SyncCycleResult result = await _engine.PushAsync(); // Assert : la donnée reste en local (aucune perte), aucun appel réseau. Assert.True(result.Executed); Assert.Equal(0, _api.SendCalls); Assert.Equal(0, _api.SnapshotCalls); Assert.Equal(0, result.OperationsSent); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Pending, loaded.State); } [Fact] public async Task EnvoiDeFondEtSyncGlobaleSimultanes_UnSeulCycleExecute() { // Arrange : envoi lent pour laisser le second cycle démarrer. _api.SendDelay = TimeSpan.FromMilliseconds(200); var op = CreateOp(SyncOperationType.Create); await OutboxRepository.AddAsync(op); // Act : synchronisation globale et envoi de fond lancés en même temps. Task globale = _engine.SyncAsync(); Task envoi = _engine.PushAsync(); SyncCycleResult[] results = await Task.WhenAll(globale, envoi); // Assert : un seul cycle exécuté, jamais deux sur la même file locale. Assert.Equal(1, results.Count(r => r.Executed)); Assert.Equal(1, results.Count(r => !r.Executed)); Assert.Equal(1, _api.SendCalls); } // --------------------------------------------------------------------- // Récupération des envois interrompus : une opération restée en « Envoi en // cours » (application fermée en pleine émission, annulation du cycle) n'est // plus jamais sélectionnée par GetReadyAsync. Le cycle doit la remettre en // attente, sinon la donnée saisie ne partirait jamais tout en restant // affichée comme « en attente ». // --------------------------------------------------------------------- [Fact] public async Task OperationResteeEnEnvoiInterrompu_EstRemiseEnAttenteEtEnvoyee() { // Arrange : opération laissée dans l'état « Envoi en cours ». var op = CreateOp(SyncOperationType.Create); op.State = OutboxState.Sending; await OutboxRepository.AddAsync(op); // Cette opération n'est pas « prête » pour le moteur : c'est exactement le // blocage constaté (elle resterait éternellement en attente). Assert.Empty(await OutboxRepository.GetReadyAsync()); // Act : envoi de fond (surveillance automatique). SyncCycleResult result = await _engine.PushAsync(); // Assert : récupérée puis réellement envoyée. Assert.Equal(1, _api.SendCalls); Assert.Equal(1, result.OperationsSent); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Succeeded, loaded.State); } [Fact] public async Task OperationEnAttenteDeResultat_NEstPasReemiseParLaRecuperation() { // Arrange : une opération déjà reçue par le serveur (en attente de résultat) // doit rester suivie par son statut, jamais réémise par la récupération. var op = CreateOp(SyncOperationType.Create); op.State = OutboxState.WaitingResult; await OutboxRepository.AddAsync(op); // Act : envoi de fond. await _engine.PushAsync(); // Assert : aucun nouvel envoi, le statut a simplement été interrogé. Assert.Equal(0, _api.SendCalls); Assert.Equal(1, _api.StatusCalls); OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Succeeded, loaded.State); } }