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.Documents; 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; /// /// Repro du bug « document ajouté sur cet appareil → synchronisé → impossible à /// ouvrir ». Vérifie que la référence locale durable du document survit à la /// synchronisation (réconciliation d'identité + snapshot + rafraîchissement). /// public class DocumentOpenAfterSyncTests : 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 DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true }; public SyncStatusResponse StatusToReturn { get; set; } = new() { Status = SyncStatus.Success }; public IReadOnlyList DocumentsToReturn { get; set; } = []; public DocumentDownloadResponse? DownloadToReturn { get; set; } public DocumentUploadRequest? LastUploadRequest { get; private set; } public Task SendOperationAsync(SyncOperation operation, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetStatusAsync(Guid operationId, CancellationToken ct = default) => Task.FromResult(StatusToReturn); public Task GetSnapshotAsync(CancellationToken ct = default) => Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(DownloadToReturn ?? new DocumentDownloadResponse { DocumentGuid = documentGuid, NomFichier = "doc.pdf", MimeType = "application/pdf", TailleOctets = 3, Contenu = [1, 2, 3], Existe = true }); public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default) { LastUploadRequest = request; return Task.FromResult(UploadToReturn); } public Task> GetDocumentsByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(DocumentsToReturn); public string? AccessToken { get; set; } public Task ActivateAsync( Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default) => throw new NotSupportedException(); public Task ValidateAsync( Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetBootstrapAsync(CancellationToken ct = default) => throw new NotSupportedException(); } private sealed class FakeFilePicker : IFilePicker { public PickedFile? DocumentToReturn { get; set; } public Task PickPhotoAsync() => Task.FromResult(null); public Task PickDocumentAsync() => Task.FromResult(DocumentToReturn); public Task> PickPhotosAsync() => Task.FromResult>([]); } private sealed class FakePhotoCapture : IPhotoCapture { public Task CapturePhotoAsync(CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeFileLauncher : IFileLauncher { public string? LastOpenedPath { get; private set; } public Task OpenAsync(string localPath) { LastOpenedPath = localPath; return Task.FromResult(true); } } private sealed class FakeDateResolver : IDocumentDateResolver { public Task ResolveAsync(string filePath, string? fileName, CancellationToken ct = default) => Task.FromResult(new DocumentDateResolution(new DateTime(2024, 5, 6, 7, 8, 9), DocumentDateSource.SystemFallback)); } private readonly FakeNetwork _network = new(); private readonly FakeApiClient _api = new(); private readonly FakeFilePicker _picker = new(); private readonly MobileIdentityReconciliationService _reconciliation; private readonly SnapshotApplier _snapshotApplier; private readonly SyncEngine _engine; private readonly DocumentLocalService _service; private readonly string _filesDirectory; public DocumentOpenAfterSyncTests() { _filesDirectory = Path.Combine(Path.GetTempPath(), "omika-tests", Guid.NewGuid().ToString("N")); _reconciliation = new MobileIdentityReconciliationService( Database, IdentityMappingRepository, OutboxRepository, []); _snapshotApplier = new SnapshotApplier(Database); _engine = new SyncEngine( _network, _api, OutboxRepository, _reconciliation, _snapshotApplier, new FakeDeviceIdProvider(), NullLogger.Instance); _service = new DocumentLocalService( DocumentMetadataRepository, NoteDocumentRepository, DocumentCacheRepository, OutboxRepository, _api, new FakeFileLauncher(), _picker, new FakePhotoCapture(), new FakeImageOptimizer(), new FakePhotoPolicyProvider(), new FakeDateResolver(), _network, _reconciliation, _filesDirectory, NullLogger.Instance); } ~DocumentOpenAfterSyncTests() { if (Directory.Exists(_filesDirectory)) { Directory.Delete(_filesDirectory, recursive: true); } } private sealed class FakeDeviceIdProvider : IDeviceIdProvider { public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult("device-test"); } private static DocumentSummaryDto CreateServerDocument(Guid id, Guid interventionId) => new() { Id = id.ToString(), Nom = "doc.pdf", Titre = "doc.pdf", Categorie = "Document", MimeType = "application/pdf", Extension = ".pdf", TailleOctets = 3, DateCreation = new DateTime(2024, 5, 6, 7, 8, 9), InterventionId = interventionId.ToString() }; [Fact] public async Task DocumentAjoute_Synchronise_AvecGuidCanonique_ResteOuvrable() { // Arrange : ajout local d'un document (fichier durable + cache + outbox). Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "doc.pdf", MimeType = "application/pdf" }; InterventionDocumentItem? added = await _service.AddDocumentAsync(interventionId); Assert.NotNull(added); Guid mobileGuid = added!.DocumentGuid; // Le serveur renvoie un GUID canonique DIFFÉRENT du GUID mobile. Guid canonicalGuid = Guid.NewGuid(); _api.UploadToReturn = new DocumentUploadResponse { Success = true, CanonicalDocumentGuid = canonicalGuid }; _api.StatusToReturn = new SyncStatusResponse { Status = SyncStatus.Success, CanonicalEntityGuid = canonicalGuid }; _api.DocumentsToReturn = [CreateServerDocument(canonicalGuid, interventionId)]; // Le fichier durable existe AVANT la synchronisation. DocumentCacheEntry? cacheBefore = await DocumentCacheRepository.GetAsync(mobileGuid); Assert.NotNull(cacheBefore); Assert.True(File.Exists(cacheBefore!.LocalPath)); // Act : synchronisation complète. await _engine.SyncAsync(); // Rechargement de la liste (comme le fait le ViewModel après la sync). IReadOnlyList items = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : le document est toujours référencé et son fichier local trouvé. InterventionDocumentItem item = Assert.Single(items); Assert.Equal(canonicalGuid, item.DocumentGuid); string? cachedPath = await _service.GetCachedFilePathAsync(canonicalGuid); Assert.NotNull(cachedPath); Assert.True(File.Exists(cachedPath)); } [Fact] public async Task DocumentAjoute_Synchronise_SansGuidCanonique_ResteOuvrable() { // Arrange : le serveur ne renvoie PAS de GUID canonique (CanonicalDocumentGuid // null) → pas de réconciliation. Le DocumentGuid mobile est conservé. Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "doc.pdf", MimeType = "application/pdf" }; InterventionDocumentItem? added = await _service.AddDocumentAsync(interventionId); Assert.NotNull(added); Guid mobileGuid = added!.DocumentGuid; _api.UploadToReturn = new DocumentUploadResponse { Success = true, CanonicalDocumentGuid = null }; _api.StatusToReturn = new SyncStatusResponse { Status = SyncStatus.Success }; _api.DocumentsToReturn = [CreateServerDocument(mobileGuid, interventionId)]; await _engine.SyncAsync(); IReadOnlyList items = await _service.GetDocumentsForInterventionAsync(interventionId); InterventionDocumentItem item = Assert.Single(items); string? cachedPath = await _service.GetCachedFilePathAsync(item.DocumentGuid); Assert.NotNull(cachedPath); Assert.True(File.Exists(cachedPath)); } }