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);
}
}