763 lines
29 KiB
C#
763 lines
29 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests du SyncEngine : offline, envoi, statuts, réconciliation, snapshot,
|
|
/// concurrence, CancellationToken, STATUS_NOT_AVAILABLE.
|
|
/// </summary>
|
|
public class SyncEngineTests : LocalTestBase
|
|
{
|
|
private sealed class FakeNetwork : INetworkConnectivity
|
|
{
|
|
public bool Online { get; set; } = true;
|
|
public Task<bool> 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<SyncOperationReceipt> 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<SyncStatusResponse> 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<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
|
|
{
|
|
SnapshotCalls++;
|
|
if (ThrowOnSnapshot)
|
|
{
|
|
throw new HttpRequestException("Snapshot error");
|
|
}
|
|
return Task.FromResult(SnapshotToReturn ?? new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
|
|
}
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.Documents.DocumentDownloadResponse> DownloadDocumentAsync(
|
|
Guid documentGuid, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.Documents.DocumentUploadResponse> UploadDocumentAsync(
|
|
Omika.Core.Contracts.Mobile.Documents.DocumentUploadRequest request, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public string? AccessToken { get; set; }
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> ActivateAsync(
|
|
Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> ValidateAsync(
|
|
Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.Bootstrap.MobileBootstrapResponse> 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<SyncEngine>.Instance);
|
|
}
|
|
|
|
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
|
|
{
|
|
public Task<string> 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<Omika.Core.Contracts.Clients.ContactDto>(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<SyncCycleResult> t1 = _engine.SyncAsync();
|
|
Task<SyncCycleResult> 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<OperationCanceledException>(() => _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<OperationCanceledException>(() => _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<SyncCycleResult> globale = _engine.SyncAsync();
|
|
Task<SyncCycleResult> 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);
|
|
}
|
|
}
|
|
|
|
|