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; /// /// Tests de synchronisation des Documents via le SyncEngine : upload du fichier /// local, conservation de OperationId/DocumentGuid, fichier manquant, offline et /// rejet serveur. /// public class DocumentSyncTests : 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 UploadCalls { get; private set; } public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true }; public DocumentUploadRequest? LastUploadRequest { get; private set; } public Task SendOperationAsync(SyncOperation operation, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetStatusAsync(Guid operationId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetSnapshotAsync(CancellationToken ct = default) => Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default) { UploadCalls++; LastUploadRequest = request; return Task.FromResult(UploadToReturn); } 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 DocumentSyncTests() { _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 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); } }