327 lines
13 KiB
C#
327 lines
13 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Omika.App.Local.Abstractions;
|
|
using Omika.App.Local.Infrastructure;
|
|
using Omika.App.Local.Models;
|
|
using Omika.App.Local.Services;
|
|
using Omika.Core.Contracts.Mobile.Documents;
|
|
using Omika.Core.Contracts.Mobile.Snapshot;
|
|
using Omika.Core.Contracts.Mobile.Sync;
|
|
using LocalSyncEntityTypes = Omika.App.Local.Infrastructure.SyncEntityTypes;
|
|
|
|
namespace Omika.Tests.Mobile.Local;
|
|
|
|
/// <summary>
|
|
/// Tests de synchronisation des Documents via le SyncEngine : upload du fichier
|
|
/// local, conservation de OperationId/DocumentGuid, fichier manquant, offline et
|
|
/// rejet serveur.
|
|
/// </summary>
|
|
public class DocumentSyncTests : 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 UploadCalls { get; private set; }
|
|
public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true };
|
|
public DocumentUploadRequest? LastUploadRequest { get; private set; }
|
|
|
|
public Task<SyncOperationReceipt> SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<SyncStatusResponse> GetStatusAsync(Guid operationId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
|
|
=> Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
|
|
|
|
public Task<DocumentDownloadResponse> DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<DocumentUploadResponse> UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default)
|
|
{
|
|
UploadCalls++;
|
|
LastUploadRequest = request;
|
|
return Task.FromResult(UploadToReturn);
|
|
}
|
|
|
|
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 DocumentSyncTests()
|
|
{
|
|
_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 string CreateTempFile(string directory, string name, byte[] content)
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
string path = Path.Combine(directory, name);
|
|
File.WriteAllBytes(path, content);
|
|
return path;
|
|
}
|
|
|
|
private static OutboxOperation CreateDocumentOp(Guid documentGuid, string localPath)
|
|
=> CreateDocumentOp(documentGuid, localPath, dateCreation: null);
|
|
|
|
private static OutboxOperation CreateDocumentOp(
|
|
Guid documentGuid,
|
|
string localPath,
|
|
DateTime? dateCreation)
|
|
{
|
|
var payload = new DocumentOutboxPayload
|
|
{
|
|
DocumentGuid = documentGuid,
|
|
InterventionGuid = Guid.NewGuid(),
|
|
NomFichier = "doc.pdf",
|
|
MimeType = "application/pdf",
|
|
Extension = ".pdf",
|
|
Categorie = "Document",
|
|
LocalPath = localPath,
|
|
DateCreation = dateCreation
|
|
};
|
|
return new OutboxOperation
|
|
{
|
|
OperationId = Guid.NewGuid(),
|
|
EntityType = LocalSyncEntityTypes.Document,
|
|
OperationType = SyncOperationType.Create,
|
|
RequestedEntityGuid = documentGuid,
|
|
Payload = JsonSerializer.Serialize(payload),
|
|
State = OutboxState.Pending
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPending_TransmetLaDateReelleDUnePhoto()
|
|
{
|
|
// Arrange : la date métier (date réelle du fichier) doit traverser la
|
|
// synchronisation jusqu'au serveur. Sans cela, le serveur horodaterait le
|
|
// document à l'instant de l'upload et le rafraîchissement à la demande
|
|
// écraserait ensuite cette valeur sur l'appareil.
|
|
var priseDeVue = new DateTime(2025, 6, 23, 16, 42, 0);
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "photo.jpg", [1, 2, 3, 4]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
await OutboxRepository.AddAsync(CreateDocumentOp(documentGuid, localPath, priseDeVue));
|
|
|
|
// Act
|
|
await _engine.SyncAsync();
|
|
|
|
// Assert
|
|
Assert.NotNull(_api.LastUploadRequest);
|
|
Assert.Equal(priseDeVue, _api.LastUploadRequest!.DateCreation);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPending_OperationHistoriqueSansDate_TransmetNull()
|
|
{
|
|
// Arrange : opération Outbox créée avant la fonctionnalité : aucune date,
|
|
// le serveur applique alors sa date courante (comportement d'origine).
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3, 4]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
await OutboxRepository.AddAsync(CreateDocumentOp(documentGuid, localPath, dateCreation: null));
|
|
|
|
// Act
|
|
await _engine.SyncAsync();
|
|
|
|
// Assert
|
|
Assert.NotNull(_api.LastUploadRequest);
|
|
Assert.Null(_api.LastUploadRequest!.DateCreation);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPending_LitFichierLocal_EtUpload()
|
|
{
|
|
// Arrange
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3, 4]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
|
|
// Act
|
|
SyncCycleResult result = await _engine.SyncAsync();
|
|
|
|
// Assert : upload effectué avec le contenu du fichier local.
|
|
Assert.Equal(1, _api.UploadCalls);
|
|
Assert.NotNull(_api.LastUploadRequest);
|
|
Assert.Equal(documentGuid, _api.LastUploadRequest.DocumentGuid);
|
|
Assert.Equal([1, 2, 3, 4], _api.LastUploadRequest.Contenu);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OperationIdConserveAuRetry()
|
|
{
|
|
// Arrange
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
Guid operationId = op.OperationId;
|
|
|
|
// Act : premier cycle (succès), puis relecture.
|
|
await _engine.SyncAsync();
|
|
|
|
// Assert : OperationId conservé (pas de nouveau GUID).
|
|
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(operationId);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(operationId, loaded.OperationId);
|
|
Assert.Equal(documentGuid, loaded.RequestedEntityGuid);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentGuidConserve()
|
|
{
|
|
// Arrange
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
|
|
// Act
|
|
await _engine.SyncAsync();
|
|
|
|
// Assert : DocumentGuid conservé dans la requête d'upload.
|
|
Assert.NotNull(_api.LastUploadRequest);
|
|
Assert.Equal(documentGuid, _api.LastUploadRequest.DocumentGuid);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FichierManquant_AucunFauxSuccess()
|
|
{
|
|
// Arrange : opération Document dont le fichier local a disparu.
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
|
|
// Supprime le fichier local avant l'upload.
|
|
File.Delete(localPath);
|
|
|
|
// Act
|
|
SyncCycleResult result = await _engine.SyncAsync();
|
|
|
|
// Assert : aucun upload, opération en Rejected local, pas de SUCCESS.
|
|
Assert.Equal(0, _api.UploadCalls);
|
|
Assert.Equal(0, result.OperationsSucceeded);
|
|
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(OutboxState.Rejected, loaded.State);
|
|
Assert.Contains("introuvable", loaded.LastError, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Offline_OperationConservee()
|
|
{
|
|
// Arrange
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
_network.Online = false;
|
|
|
|
// Act
|
|
SyncCycleResult result = await _engine.SyncAsync();
|
|
|
|
// Assert : aucun upload, opération conservée en Pending.
|
|
Assert.Equal(0, _api.UploadCalls);
|
|
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(OutboxState.Pending, loaded.State);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServeurRejected_EtatConserve()
|
|
{
|
|
// Arrange
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
|
|
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
|
|
Guid documentGuid = Guid.NewGuid();
|
|
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
|
|
await OutboxRepository.AddAsync(op);
|
|
_api.UploadToReturn = new DocumentUploadResponse
|
|
{
|
|
Success = false,
|
|
ErrorCode = "ERR001",
|
|
ErrorMessage = "Rejeté par le serveur"
|
|
};
|
|
|
|
// Act
|
|
SyncCycleResult result = await _engine.SyncAsync();
|
|
|
|
// Assert : opération en Rejected, erreur conservée.
|
|
Assert.Equal(1, _api.UploadCalls);
|
|
Assert.Equal(0, result.OperationsSucceeded);
|
|
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(OutboxState.Rejected, loaded.State);
|
|
Assert.Contains("ERR001", loaded.LastError);
|
|
|
|
// Nettoyage.
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
}
|