using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Omika.App.Local.Abstractions; 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; /// /// Tests du service local Documents / Photos : liste depuis SQLite, cache /// fichier local, téléchargement à la demande, ouverture offline et ajout /// photo/document offline-first (Outbox). /// public class DocumentLocalServiceTests : 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 DownloadCalls { get; private set; } public int UploadCalls { get; private set; } public int GetDocumentsCalls { get; private set; } public bool ThrowOnDownload { get; set; } public bool ThrowOnGetDocuments { get; set; } = true; public IReadOnlyList DocumentsByInterventionToReturn { get; set; } = []; public DocumentDownloadResponse? DownloadToReturn { get; set; } public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true }; public Task> GetDocumentsByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) { GetDocumentsCalls++; if (ThrowOnGetDocuments) { throw new HttpRequestException("Refresh documents error"); } return Task.FromResult(DocumentsByInterventionToReturn); } 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) => throw new NotImplementedException(); public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default) { DownloadCalls++; if (ThrowOnDownload) { throw new HttpRequestException("Download error"); } return Task.FromResult(DownloadToReturn ?? new DocumentDownloadResponse { DocumentGuid = documentGuid, NomFichier = "doc.pdf", MimeType = "application/pdf", TailleOctets = 10, Contenu = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }); } public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default) { UploadCalls++; return Task.FromResult(UploadToReturn); } public int GetNotesByDocumentCalls { get; private set; } public IReadOnlyList NotesByDocumentToReturn { get; set; } = []; public Exception? NotesByDocumentException { get; set; } public Task> GetNotesByDocumentAsync(Guid documentGuid, CancellationToken ct = default) { GetNotesByDocumentCalls++; return NotesByDocumentException is not null ? Task.FromException>(NotesByDocumentException) : Task.FromResult(NotesByDocumentToReturn); } 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 sealed class FakeFileLauncher : IFileLauncher { public string? LastOpenedPath { get; private set; } public bool Result { get; set; } = true; public Task OpenAsync(string localPath) { LastOpenedPath = localPath; return Task.FromResult(Result); } } private sealed class FakeFilePicker : IFilePicker { public PickedFile? PhotoToReturn { get; set; } public PickedFile? DocumentToReturn { get; set; } public IReadOnlyList PhotosToReturn { get; set; } = []; public Task PickPhotoAsync() => Task.FromResult(PhotoToReturn); public Task PickDocumentAsync() => Task.FromResult(DocumentToReturn); public Task> PickPhotosAsync() => Task.FromResult(PhotosToReturn); } private sealed class FakePhotoCapture : IPhotoCapture { public PickedFile? PhotoToReturn { get; set; } public Exception? ExceptionToThrow { get; set; } public Task CapturePhotoAsync(CancellationToken ct = default) { if (ExceptionToThrow is not null) { return Task.FromException(ExceptionToThrow); } return Task.FromResult(PhotoToReturn); } } private readonly FakeNetwork _network = new(); private readonly FakeApiClient _api = new(); private readonly FakeFileLauncher _launcher = new(); private readonly FakeFilePicker _picker = new(); private readonly FakePhotoCapture _photoCapture = new(); private readonly FakeImageOptimizer _optimizer = new(); private readonly FakePhotoPolicyProvider _policy = new(); private readonly FakeDocumentDateResolver _dateResolver = new(); private readonly string _filesDirectory; private readonly DocumentLocalService _service; public DocumentLocalServiceTests() { _filesDirectory = Path.Combine(Path.GetTempPath(), "omika-tests", Guid.NewGuid().ToString("N")); _service = new DocumentLocalService( DocumentMetadataRepository, NoteDocumentRepository, DocumentCacheRepository, OutboxRepository, _api, _launcher, _picker, _photoCapture, _optimizer, _policy, _dateResolver, _network, ReconciliationService, _filesDirectory, NullLogger.Instance); } ~DocumentLocalServiceTests() { if (Directory.Exists(_filesDirectory)) { Directory.Delete(_filesDirectory, recursive: true); } } private static string Serialize(T value) => JsonSerializer.Serialize(value); private static LocalDocumentMetadata CreateDocument( Guid id, Guid interventionId, string nom, string extension, string? categorie = "Rapport", string? mimeType = null, DateTime? dateCreation = null) { var dto = new DocumentSummaryDto { Id = id.ToString(), Nom = nom, Titre = nom, Categorie = categorie, MimeType = mimeType, Extension = extension, TailleOctets = 1024, DateCreation = dateCreation ?? new DateTime(2026, 8, 1), InterventionId = interventionId.ToString() }; return new LocalDocumentMetadata { Id = id, InterventionId = interventionId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalDocumentMetadata CreatePhoto( Guid id, Guid interventionId, string nom, string extension, string? categorie = "Photo", string? mimeType = "image/jpeg") => CreateDocument(id, interventionId, nom, extension, categorie, mimeType); // ===================== TESTS LISTE ===================== [Fact] public async Task DocumentsDuneIntervention_Retournes() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "rapport.pdf", ".pdf")); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert Assert.Single(result); Assert.Equal(docId, result[0].DocumentGuid); Assert.Equal("rapport.pdf", result[0].Nom); Assert.Equal(".pdf", result[0].Extension); } [Fact] public async Task AucunDocument_RetourneListeVide() { // Arrange Guid interventionId = Guid.NewGuid(); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert Assert.Empty(result); } [Fact] public async Task OrdreCohérent_PhotosDabordPuisParDate() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docTard = Guid.NewGuid(); Guid photo = Guid.NewGuid(); Guid docTot = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docTard, interventionId, "tard.pdf", ".pdf", dateCreation: new DateTime(2026, 8, 10))); await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "photo.jpg", ".jpg")); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docTot, interventionId, "tot.pdf", ".pdf", dateCreation: new DateTime(2026, 8, 2))); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : la photo d'abord, puis les documents par date croissante. Assert.Equal(3, result.Count); Assert.Equal(photo, result[0].DocumentGuid); Assert.Equal(docTot, result[1].DocumentGuid); Assert.Equal(docTard, result[2].DocumentGuid); } [Fact] public async Task DocumentGuidConserve() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert Assert.Equal(docId, result[0].DocumentGuid); } [Fact] public async Task MetadataInvalide_IgnoreeProprement() { // Arrange Guid interventionId = Guid.NewGuid(); Guid valide = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(valide, interventionId, "ok.pdf", ".pdf")); await DocumentMetadataRepository.UpsertAsync(new LocalDocumentMetadata { Id = Guid.NewGuid(), InterventionId = interventionId, PayloadJson = "not-json", UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : seule la metadata valide est retournée. Assert.Single(result); Assert.Equal(valide, result[0].DocumentGuid); } [Fact] public async Task AucunTelechargementAutomatique() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); // Act : ouverture de l'écran (liste). await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : aucun appel HTTP de téléchargement. Assert.Equal(0, _api.DownloadCalls); } [Fact] public async Task DistinctionPhotoDocument_ParCategorie() { // Arrange Guid interventionId = Guid.NewGuid(); Guid photo = Guid.NewGuid(); Guid doc = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "photo.jpg", ".jpg", categorie: "Photo")); await DocumentMetadataRepository.UpsertAsync(CreateDocument(doc, interventionId, "rapport.pdf", ".pdf", categorie: "Rapport")); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert Assert.True(result.Single(d => d.DocumentGuid == photo).IsPhoto); Assert.False(result.Single(d => d.DocumentGuid == doc).IsPhoto); } [Fact] public async Task DistinctionPhotoDocument_ParMimeType() { // Arrange Guid interventionId = Guid.NewGuid(); Guid photo = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "image.png", ".png", categorie: null, mimeType: "image/png")); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : distinguée par MIME type même sans catégorie. Assert.True(result[0].IsPhoto); } [Fact] public async Task SansDonneeFiable_PresenteCommeDocument() { // Arrange Guid interventionId = Guid.NewGuid(); Guid doc = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(doc, interventionId, "fichier.bin", ".bin", categorie: null, mimeType: null)); // Act IReadOnlyList result = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : présenté comme document (pas de distinction fiable). Assert.False(result[0].IsPhoto); } // ===================== TESTS CACHE ===================== [Fact] public async Task DejaEnCache_AucunHTTP() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); string localPath = Path.Combine(_filesDirectory, "cached.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(localPath, [1, 2, 3]); await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry { DocumentGuid = docId, LocalPath = localPath, NomFichier = "doc.pdf", State = DocumentCacheState.Downloaded }); // Act bool result = await _service.EnsureDownloadedAsync(docId); // Assert : aucun HTTP, déjà en cache. Assert.True(result); Assert.Equal(0, _api.DownloadCalls); } [Fact] public async Task NonEnCache_Online_TelechargeEtSauvegarde() { // Arrange Guid docId = Guid.NewGuid(); _api.DownloadToReturn = new DocumentDownloadResponse { DocumentGuid = docId, NomFichier = "doc.pdf", MimeType = "application/pdf", TailleOctets = 4, Contenu = [9, 8, 7, 6] }; // Act bool result = await _service.EnsureDownloadedAsync(docId); // Assert : téléchargé, fichier écrit, entrée cache créée. Assert.True(result); Assert.Equal(1, _api.DownloadCalls); DocumentCacheEntry? cache = await DocumentCacheRepository.GetAsync(docId); Assert.NotNull(cache); Assert.Equal(DocumentCacheState.Downloaded, cache.State); Assert.True(File.Exists(cache.LocalPath)); Assert.Equal(4, new FileInfo(cache.LocalPath).Length); } [Fact] public async Task NonEnCache_Offline_AucunHTTP_MetadataVisible() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); _network.Online = false; // Act : la liste est visible, le téléchargement échoue sans HTTP. IReadOnlyList list = await _service.GetDocumentsForInterventionAsync(interventionId); bool download = await _service.EnsureDownloadedAsync(docId); // Assert Assert.Single(list); Assert.False(list[0].IsCached); Assert.False(download); Assert.Equal(0, _api.DownloadCalls); } [Fact] public async Task EntreeCachePresenteMaisFichierAbsent_ConsidereInvalide() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); // Entrée cache pointant vers un fichier inexistant. await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry { DocumentGuid = docId, LocalPath = Path.Combine(_filesDirectory, "absent.pdf"), NomFichier = "doc.pdf", State = DocumentCacheState.Downloaded }); // Act IReadOnlyList list = await _service.GetDocumentsForInterventionAsync(interventionId); bool download = await _service.EnsureDownloadedAsync(docId); // Assert : le cache est considéré invalide (fichier absent), un // téléchargement est tenté (online). Assert.False(list[0].IsCached); Assert.True(download); Assert.Equal(1, _api.DownloadCalls); } [Fact] public async Task SnapshotRefresh_CacheConserve() { // Arrange Guid interventionId = Guid.NewGuid(); Guid docId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); string localPath = Path.Combine(_filesDirectory, "cached.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(localPath, [1, 2, 3]); await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry { DocumentGuid = docId, LocalPath = localPath, NomFichier = "doc.pdf", State = DocumentCacheState.Downloaded }); // Simule un refresh snapshot : remplace la metadata métier. await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf")); // Act IReadOnlyList list = await _service.GetDocumentsForInterventionAsync(interventionId); // Assert : le cache est retrouvé grâce au DocumentGuid. Assert.True(list[0].IsCached); } // ===================== TESTS OUVERTURE ===================== [Fact] public async Task OuvrirDocumentEnCache_AucunHTTP() { // Arrange Guid docId = Guid.NewGuid(); string localPath = Path.Combine(_filesDirectory, "cached.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(localPath, [1, 2, 3]); await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry { DocumentGuid = docId, LocalPath = localPath, NomFichier = "cached.pdf", State = DocumentCacheState.Downloaded }); // Act bool result = await _service.OpenAsync(docId); // Assert : ouverture de la copie locale, aucun HTTP. Assert.True(result); Assert.Equal(0, _api.DownloadCalls); Assert.Equal(localPath, _launcher.LastOpenedPath); } [Fact] public async Task OuvrirDocumentNonEnCache_RetourneFalse() { // Arrange : hors ligne, le repli de téléchargement est impossible. _network.Online = false; // Act bool result = await _service.OpenAsync(Guid.NewGuid()); // Assert Assert.False(result); Assert.Null(_launcher.LastOpenedPath); Assert.Equal(0, _api.DownloadCalls); } // ===================== TESTS AJOUT ===================== [Fact] public async Task AjoutPhotoOffline_DocumentGuidGenere() { // Arrange Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert : DocumentGuid généré, photo, en cache, en attente de sync. Assert.NotNull(item); Assert.NotEqual(Guid.Empty, item.DocumentGuid); Assert.True(item.IsPhoto); Assert.True(item.IsCached); Assert.Equal(DocumentSyncState.Pending, item.SyncState); } [Fact] public async Task AjoutDocumentOffline_DocumentGuidGenere() { // Arrange 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" }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert Assert.NotNull(item); Assert.NotEqual(Guid.Empty, item.DocumentGuid); Assert.False(item.IsPhoto); Assert.True(item.IsCached); Assert.Equal(DocumentSyncState.Pending, item.SyncState); } [Fact] public async Task Ajout_OperationIdGenereEtDistinct() { // Arrange 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" }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert : une opération Outbox existe avec OperationId ≠ DocumentGuid. Assert.NotNull(item); IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = Assert.Single(ops); Assert.Equal(LocalSyncEntityTypes.Document, op.EntityType); Assert.Equal(item.DocumentGuid, op.RequestedEntityGuid); Assert.NotEqual(item.DocumentGuid, op.OperationId); } [Fact] public async Task Ajout_FichierCopieLocalement() { // Arrange 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" }; // Act await _service.AddDocumentAsync(interventionId); // Assert : le fichier est copié dans l'espace contrôlé. IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = Assert.Single(ops); DocumentOutboxPayload? payload = JsonSerializer.Deserialize(op.Payload); Assert.NotNull(payload); Assert.True(File.Exists(payload.LocalPath)); Assert.NotEqual(source, payload.LocalPath); } [Fact] public async Task Ajout_MetadataImmediatementVisible() { // Arrange 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" }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert : la metadata est visible dans la liste. IReadOnlyList list = await _service.GetDocumentsForInterventionAsync(interventionId); Assert.Contains(list, d => d.DocumentGuid == item!.DocumentGuid); } [Fact] public async Task Ajout_OutboxCreee() { // Arrange 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" }; // Act await _service.AddDocumentAsync(interventionId); // Assert IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); Assert.Single(ops); Assert.Equal(OutboxState.Pending, ops[0].State); } [Fact] public async Task Ajout_Redemarrage_OperationToujoursExploitable() { // Arrange 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" }; // Act : ajout puis "redémarrage" (relecture depuis SQLite/filesystem). InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = Assert.Single(ops); DocumentOutboxPayload? payload = JsonSerializer.Deserialize(op.Payload); // Assert : la référence locale persiste et le fichier est toujours là. Assert.NotNull(payload); Assert.True(File.Exists(payload.LocalPath)); Assert.Equal(item!.DocumentGuid, payload.DocumentGuid); } [Fact] public async Task Ajout_Success_FichierConserve() { // Arrange 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? item = await _service.AddDocumentAsync(interventionId); // Act : simule un SUCCESS (opération Succeeded). IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = ops.Single(o => o.RequestedEntityGuid == item!.DocumentGuid); op.State = OutboxState.Succeeded; await OutboxRepository.UpdateAsync(op); // Assert : le fichier local est conservé après SUCCESS. DocumentOutboxPayload? payload = JsonSerializer.Deserialize(op.Payload); Assert.NotNull(payload); Assert.True(File.Exists(payload.LocalPath)); } [Fact] public async Task Ajout_Rejected_FichierConserve() { // Arrange 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? item = await _service.AddDocumentAsync(interventionId); // Act : simule un REJECTED (opération Rejected). IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = ops.Single(o => o.RequestedEntityGuid == item!.DocumentGuid); op.State = OutboxState.Rejected; op.LastError = "ERR001: Rejeté"; await OutboxRepository.UpdateAsync(op); // Assert : le fichier local est conservé après REJECTED. DocumentOutboxPayload? payload = JsonSerializer.Deserialize(op.Payload); Assert.NotNull(payload); Assert.True(File.Exists(payload.LocalPath)); // La liste reflète l'état Rejected. IReadOnlyList list = await _service.GetDocumentsForInterventionAsync(interventionId); InterventionDocumentItem rejected = list.Single(d => d.DocumentGuid == item!.DocumentGuid); Assert.Equal(DocumentSyncState.Rejected, rejected.SyncState); Assert.True(rejected.IsRejectedSync); } [Fact] public async Task Ajout_SourceTemporaireSupprimee_CopieInterneExploitable() { // Arrange 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" }; // Act await _service.AddDocumentAsync(interventionId); // Supprime la source temporaire (comme le ferait le système). File.Delete(source); // Assert : la copie interne est toujours exploitable. IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); OutboxOperation op = Assert.Single(ops); DocumentOutboxPayload? payload = JsonSerializer.Deserialize(op.Payload); Assert.NotNull(payload); Assert.True(File.Exists(payload.LocalPath)); Assert.NotEqual(source, payload.LocalPath); } // ===================== TESTS POLITIQUE DE POIDS DES PHOTOS ===================== [Fact] public async Task AjoutPhoto_Image_SoumiseALaPolitiqueDePoids() { // Arrange : limite client de 500 Ko. _policy.MaxSizeKb = 500; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act await _service.AddPhotoAsync(interventionId); // Assert : l'optimiseur a été appelé avec la limite du client. Assert.Equal(1, _optimizer.Calls); Assert.Equal(500, _optimizer.LastMaxSizeKb); } [Fact] public async Task AjoutPhoto_AucuneLimite_ImageNonTraitee() { // Arrange : 0 = aucune limite (politique client désactivée). _policy.MaxSizeKb = 0; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act await _service.AddPhotoAsync(interventionId); // Assert : aucun traitement, le document est bien créé. Assert.Equal(0, _optimizer.Calls); } [Fact] public async Task AjoutDocument_Pdf_JamaisOptimise() { // Arrange : même avec une limite active, un document non image n'est // jamais soumis à l'optimisation. _policy.MaxSizeKb = 500; 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" }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert : contenu copié à l'identique, optimizeur non appelé. Assert.Equal(0, _optimizer.Calls); Assert.NotNull(item); Assert.Equal(".pdf", item.Extension); } [Fact] public async Task AjoutDocument_ImageViaSelecteur_Optimisee() { // Arrange : le sélecteur de documents peut renvoyer une image ; elle doit // alors être traitée comme telle. _policy.MaxSizeKb = 2048; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.png"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "scan.png", MimeType = "image/png" }; // Act await _service.AddDocumentAsync(interventionId); // Assert Assert.Equal(1, _optimizer.Calls); Assert.Equal(2048, _optimizer.LastMaxSizeKb); } [Fact] public async Task AjoutPhoto_ImageOptimisee_TailleEtFichierRefletentLeResultat() { // Arrange : l'optimiseur remplace le contenu par une version réduite. _policy.MaxSizeKb = 500; _optimizer.OptimizedBytes = [9, 9, 9, 9]; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3, 4, 5, 6, 7, 8]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert : la taille annoncée est celle du fichier optimisé, et le fichier // local référencé par l'Outbox est bien la version optimisée. Assert.NotNull(item); Assert.Equal(4, item.TailleOctets); IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); DocumentOutboxPayload? payload = JsonSerializer.Deserialize(Assert.Single(ops).Payload); Assert.NotNull(payload); Assert.Equal([9, 9, 9, 9], await File.ReadAllBytesAsync(payload.LocalPath)); } [Fact] public async Task AjoutPhoto_UnSeulPassageDOptimisation() { // Arrange : garantit qu'une image n'est optimisée qu'UNE fois à l'ajout. _policy.MaxSizeKb = 500; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act await _service.AddPhotoAsync(interventionId); int apresAjout = _optimizer.Calls; // La synchronisation relit le fichier local, sans repasser par l'optimiseur. await _service.GetDocumentsForInterventionAsync(interventionId); await _service.GetCachedFilePathAsync( (await OutboxRepository.GetReadyAsync()).Single().RequestedEntityGuid); // Assert Assert.Equal(1, apresAjout); Assert.Equal(1, _optimizer.Calls); } [Fact] public async Task AjoutPhotoAnnotee_Png_SoumisALaPolitiqueDePoids() { // Arrange : le PNG produit par l'éditeur d'annotation est une nouvelle // image : il relève de la même politique. _policy.MaxSizeKb = 1024; _optimizer.OptimizedBytes = [7, 7]; Guid interventionId = Guid.NewGuid(); // Act InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync( interventionId, Guid.Empty, [1, 2, 3, 4], "annotation.png"); // Assert Assert.NotNull(item); Assert.Equal(1, _optimizer.Calls); Assert.Equal(1024, _optimizer.LastMaxSizeKb); Assert.Equal("image/png", item.MimeType); Assert.Equal(2, item.TailleOctets); } [Fact] public async Task AjoutPhotoAnnotee_AucuneLimite_PngConserve() { // Arrange _policy.MaxSizeKb = 0; Guid interventionId = Guid.NewGuid(); // Act InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync( interventionId, Guid.Empty, [1, 2, 3, 4], "annotation.png"); // Assert : aucun traitement, contenu d'origine conservé. Assert.NotNull(item); Assert.Equal(0, _optimizer.Calls); Assert.Equal(4, item.TailleOctets); } [Fact] public async Task AjoutPhotoAnnotee_HeriteDeLaDateDuDocumentSource() { // Arrange : la copie annotée est un DÉRIVÉ d'une photo existante. Le PNG // produit par l'éditeur ne porte aucune métadonnée EXIF : la date doit donc // être héritée de la photo source, pas recalculée. var sourceDate = new DateTime(2025, 6, 23, 16, 42, 0); Guid interventionId = Guid.NewGuid(); Guid sourceDocumentId = Guid.NewGuid(); await DocumentMetadataRepository.UpsertAsync( CreateDocument(sourceDocumentId, interventionId, "IMG_001.jpg", ".jpg", categorie: "Photo", mimeType: "image/jpeg", dateCreation: sourceDate)); // Act InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync( interventionId, sourceDocumentId, [1, 2, 3, 4], "IMG_001_annotee.png"); // Assert Assert.NotNull(item); Assert.Equal(sourceDate, item.DateCreation); } [Fact] public async Task AjoutPhotoAnnotee_DocumentSourceInconnu_RetombeSurLaDateSysteme() { // Arrange : identité source non résolue localement (cas d'un rafraîchissement // partiel) : l'ajout ne doit jamais échouer pour autant. Guid interventionId = Guid.NewGuid(); DateTime avant = DateTime.Now.AddSeconds(-1); // Act InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync( interventionId, Guid.NewGuid(), [1, 2, 3, 4], "annotation.png"); // Assert Assert.NotNull(item); Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task AjoutPhoto_DateExif_RemplaceLaDateDUpload() { // Arrange : photo prise le 10/09/2026 à 14:32, sélectionnée bien plus tard. // La date du document doit être celle de la PRISE DE VUE. var datePriseDeVue = new DateTime(2026, 9, 10, 14, 32, 18); _dateResolver.ResolvedDate = datePriseDeVue; _dateResolver.Source = DocumentDateSource.ExifDateTimeOriginal; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "IMG_4521.jpg", MimeType = "image/jpeg" }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert : l'élément retourné porte la date réelle… Assert.NotNull(item); Assert.Equal(datePriseDeVue, item.DateCreation); // …la métadonnée locale aussi (donc visible hors ligne et après redémarrage)… IReadOnlyList locaux = await DocumentMetadataRepository.GetAllAsync(); DocumentSummaryDto? dto = JsonSerializer.Deserialize( Assert.Single(locaux).PayloadJson); Assert.NotNull(dto); Assert.Equal(datePriseDeVue, dto.DateCreation); // …et l'Outbox la transporte jusqu'au serveur, sans quoi celui-ci // horodaterait le document à l'instant de la synchronisation. IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); DocumentOutboxPayload? payload = JsonSerializer.Deserialize( Assert.Single(ops).Payload); Assert.NotNull(payload); Assert.Equal(datePriseDeVue, payload.DateCreation); Assert.Equal(nameof(DocumentDateSource.ExifDateTimeOriginal), payload.DateCreationSource); } [Fact] public async Task AjoutPhoto_SansMetadonnee_UtiliseLaDateSysteme() { // Arrange : capture d'écran sans EXIF — le double retourne le repli système. Guid interventionId = Guid.NewGuid(); DateTime avant = DateTime.Now.AddSeconds(-1); string source = Path.Combine(_filesDirectory, "source.png"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "capture.png", MimeType = "image/png" }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert : aucune erreur, et la date correspond au moment de l'ajout. Assert.NotNull(item); Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task AjoutPhoto_DateLueAvantOptimisation() { // Arrange : l'optimisation réencode l'image et peut supprimer les // métadonnées EXIF. La date doit donc être lue SUR LE FICHIER SOURCE, et // avant que l'optimiseur ne soit appelé. _policy.MaxSizeKb = 500; _optimizer.OptimizedBytes = [9, 9]; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act await _service.AddPhotoAsync(interventionId); // Assert : la date a été résolue sur le chemin d'origine, jamais sur le // chemin du fichier optimisé. Assert.Equal(1, _dateResolver.Calls); Assert.Equal(source, _dateResolver.LastPath); } [Fact] public async Task AjoutPhoto_SansLimiteDePoids_LaDateEstQuandMemeResolue() { // Arrange : POIDS_MAX_PHOTO_KO = 0 (aucune limite) court-circuite // l'optimisation ; la date métier ne doit pas en dépendre. _policy.MaxSizeKb = 0; _dateResolver.ResolvedDate = new DateTime(2024, 3, 4, 5, 6, 7); Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert Assert.Equal(0, _optimizer.Calls); Assert.Equal(1, _dateResolver.Calls); Assert.NotNull(item); Assert.Equal(new DateTime(2024, 3, 4, 5, 6, 7), item.DateCreation); } [Fact] public async Task AjoutDocument_NonImage_LaDateEstQuandMemeResolue() { // Arrange : un PDF passe par le même point d'entrée : la résolution de date // y est tentée aussi (elle retombe sur le repli système si rien de fiable). _policy.MaxSizeKb = 500; _dateResolver.ResolvedDate = new DateTime(2023, 1, 2, 3, 4, 5); 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" }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert Assert.Equal(1, _dateResolver.Calls); Assert.NotNull(item); Assert.Equal(new DateTime(2023, 1, 2, 3, 4, 5), item.DateCreation); } // ===================== TESTS MULTI-PHOTO & CAMÉRA ===================== [Fact] public async Task AddPhotos_PickerVide_AucunAjout() { // Arrange Guid interventionId = Guid.NewGuid(); _picker.PhotosToReturn = []; // Act AddPhotosResult result = await _service.AddPhotosAsync(interventionId); // Assert : sélection annulée → aucun document, aucune opération Outbox. Assert.True(result.Cancelled); Assert.Empty(result.Added); Assert.Equal(0, result.FailedCount); Assert.Empty(await OutboxRepository.GetReadyAsync()); } [Fact] public async Task AddPhotos_UneImage_UnDocumentCree() { // Arrange Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "p1.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotosToReturn = [ new PickedFile { SourcePath = source, FileName = "p1.jpg", MimeType = "image/jpeg" } ]; // Act AddPhotosResult result = await _service.AddPhotosAsync(interventionId); // Assert InterventionDocumentItem added = Assert.Single(result.Added); Assert.Equal(0, result.FailedCount); Assert.True(added.IsPhoto); Assert.Single(await OutboxRepository.GetReadyAsync()); } [Fact] public async Task AddPhotos_TroisImages_TroisDocumentsCrees() { // Arrange Guid interventionId = Guid.NewGuid(); Directory.CreateDirectory(_filesDirectory); var picked = new List(); for (int i = 0; i < 3; i++) { string source = Path.Combine(_filesDirectory, $"p{i}.jpg"); await File.WriteAllBytesAsync(source, [(byte)i, 2, 3]); picked.Add(new PickedFile { SourcePath = source, FileName = $"p{i}.jpg", MimeType = "image/jpeg" }); } _picker.PhotosToReturn = picked; // Act AddPhotosResult result = await _service.AddPhotosAsync(interventionId); // Assert : 3 documents distincts, 3 opérations Outbox, aucun échec. Assert.Equal(3, result.Added.Count); Assert.Equal(0, result.FailedCount); Assert.Equal(3, result.Added.Select(a => a.DocumentGuid).Distinct().Count()); Assert.Equal(3, (await OutboxRepository.GetReadyAsync()).Count); } [Fact] public async Task AddPhotos_UneEchoue_LesAutresRestentTraitees() { // Arrange : 3 fichiers, celui du milieu n'existe pas (sa préparation échouera). Guid interventionId = Guid.NewGuid(); Directory.CreateDirectory(_filesDirectory); string ok1 = Path.Combine(_filesDirectory, "ok1.jpg"); string ok2 = Path.Combine(_filesDirectory, "ok2.jpg"); await File.WriteAllBytesAsync(ok1, [1, 2, 3]); await File.WriteAllBytesAsync(ok2, [4, 5, 6]); string missing = Path.Combine(_filesDirectory, "ko.jpg"); _picker.PhotosToReturn = [ new PickedFile { SourcePath = ok1, FileName = "ok1.jpg", MimeType = "image/jpeg" }, new PickedFile { SourcePath = missing, FileName = "ko.jpg", MimeType = "image/jpeg" }, new PickedFile { SourcePath = ok2, FileName = "ok2.jpg", MimeType = "image/jpeg" } ]; // Act AddPhotosResult result = await _service.AddPhotosAsync(interventionId); // Assert : les 2 succès sont conservés, l'échec est compté sans interrompre. Assert.Equal(2, result.Added.Count); Assert.Equal(1, result.FailedCount); Assert.Equal(2, (await OutboxRepository.GetReadyAsync()).Count); } [Fact] public async Task AddPhotoFromCamera_Annule_AucunDocument() { // Arrange Guid interventionId = Guid.NewGuid(); _photoCapture.PhotoToReturn = null; // Act InterventionDocumentItem? item = await _service.AddPhotoFromCameraAsync(interventionId); // Assert : aucun document, aucune opération Outbox, aucune erreur. Assert.Null(item); Assert.Empty(await OutboxRepository.GetReadyAsync()); } [Fact] public async Task AddPhotoFromCamera_PhotoRetournee_MemePipelineQuePicker() { // Arrange : la caméra produit un fichier temporaire contrôlé par Omika. Guid interventionId = Guid.NewGuid(); string tempCapture = Path.Combine(_filesDirectory, "capture.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(tempCapture, [1, 2, 3]); _photoCapture.PhotoToReturn = new PickedFile { SourcePath = tempCapture, FileName = "capture.jpg", MimeType = "image/jpeg" }; // Act InterventionDocumentItem? item = await _service.AddPhotoFromCameraAsync(interventionId); // Assert : même traitement qu'une image sélectionnée (photo, cache, Outbox). Assert.NotNull(item); Assert.True(item.IsPhoto); Assert.True(item.IsCached); Assert.Equal(DocumentSyncState.Pending, item.SyncState); Assert.Single(await OutboxRepository.GetReadyAsync()); // Le fichier temporaire caméra a été consommé puis supprimé. Assert.False(File.Exists(tempCapture)); } [Fact] public async Task AddPhotoFromCamera_LauncherEchoue_PropageErreurControlee() { // Arrange Guid interventionId = Guid.NewGuid(); _photoCapture.ExceptionToThrow = new PhotoCaptureException("Caméra indisponible."); // Act + Assert : erreur contrôlée (pas d'exception générique), aucun document. await Assert.ThrowsAsync( () => _service.AddPhotoFromCameraAsync(interventionId)); Assert.Empty(await OutboxRepository.GetReadyAsync()); } // ===================== TESTS DATE DOCUMENT (SourceDate) ===================== [Fact] public async Task AjoutDocument_AvecSourceDate_UtiliseLaDateSource() { // Arrange : le sélecteur fournit une date source (SAF LastModified). var sourceDate = new DateTimeOffset(2023, 11, 22, 10, 30, 0, TimeSpan.FromHours(1)); 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", SourceDate = sourceDate }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert : la date du document est la date source (pas la date du jour). Assert.NotNull(item); Assert.Equal(sourceDate.ToLocalTime().DateTime, item.DateCreation); // La date source traverse l'Outbox (upload serveur) avec son origine. IReadOnlyList ops = await OutboxRepository.GetReadyAsync(); DocumentOutboxPayload? payload = JsonSerializer.Deserialize( Assert.Single(ops).Payload); Assert.NotNull(payload); Assert.Equal(sourceDate.ToLocalTime().DateTime, payload.DateCreation); Assert.Equal(nameof(DocumentDateSource.SourceFileDate), payload.DateCreationSource); } [Fact] public async Task AjoutDocument_SansSourceDate_RetombeSurLaDateDUpload() { // Arrange : aucune date source (fournisseur SAF muet) → repli date système. Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.pdf"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); DateTime avant = DateTime.Now.AddSeconds(-1); _picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "doc.pdf", MimeType = "application/pdf", SourceDate = null }; // Act InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId); // Assert : repli sur la date système de l'ajout. Assert.NotNull(item); Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task AjoutPhoto_AvecSourceDate_IgnoreLaDateSource_EtConserveExif() { // Arrange : une photo porte une date source parasite ; la date EXIF reste // prioritaire (la logique photo ne doit PAS être modifiée). var datePriseDeVue = new DateTime(2026, 9, 10, 14, 32, 18); _dateResolver.ResolvedDate = datePriseDeVue; _dateResolver.Source = DocumentDateSource.ExifDateTimeOriginal; Guid interventionId = Guid.NewGuid(); string source = Path.Combine(_filesDirectory, "source.jpg"); Directory.CreateDirectory(_filesDirectory); await File.WriteAllBytesAsync(source, [1, 2, 3]); _picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg", SourceDate = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero) }; // Act InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId); // Assert : la date EXIF est conservée, la SourceDate est ignorée. Assert.NotNull(item); Assert.Equal(datePriseDeVue, item.DateCreation); } /// /// Hors ligne, l'ajout d'une note sur une photo doit aboutir : la note est /// enregistrée sur l'appareil et aucune connexion n'est tentée. C'est le /// scénario terrain (intervention sans réseau) qui était impossible avant : /// les notes d'un document n'existaient que côté serveur. /// [Fact] public async Task NotesDocument_HorsLigne_AjoutLocalSansAucunAppelReseau() { // Arrange _network.Online = false; Guid documentId = Guid.NewGuid(); // Act DocumentNoteItem note = await _service.AddNoteAsync(documentId, " Radiateur HS "); // Assert : la note existe en local, nettoyée, et rien n'est parti au serveur. Assert.Equal("Radiateur HS", note.Contenu); Assert.True(note.EnAttenteSynchronisation); Assert.Equal(0, _api.GetNotesByDocumentCalls); IReadOnlyList locales = await NoteDocumentRepository.GetByDocumentAsync(documentId); Assert.Equal("Radiateur HS", Assert.Single(locales).Contenu); } /// /// Hors ligne, la liste des notes est servie depuis l'appareil : elle contient /// la note saisie, marquée comme en attente d'envoi, et aucun appel HTTP n'est /// tenté. /// [Fact] public async Task NotesDocument_HorsLigne_ListeServieDepuisLAppareil() { // Arrange _network.Online = false; Guid documentId = Guid.NewGuid(); await _service.AddNoteAsync(documentId, "Note saisie hors ligne"); // Act IReadOnlyList notes = await _service.GetNotesForDocumentAsync(documentId); // Assert DocumentNoteItem note = Assert.Single(notes); Assert.Equal("Note saisie hors ligne", note.Contenu); Assert.Equal("En attente d'envoi", note.SyncLabel); Assert.Equal(0, _api.GetNotesByDocumentCalls); } /// /// En ligne, les notes confirmées par le serveur sont mises en cache local puis /// servies depuis l'appareil (elles restent donc consultables hors ligne). /// [Fact] public async Task NotesDocument_EnLigne_NotesServeurMisesEnCache() { // Arrange Guid documentId = Guid.NewGuid(); Guid noteId = Guid.NewGuid(); _api.NotesByDocumentToReturn = [ new NoteInterneDto { Id = noteId.ToString(), Contenu = "Note serveur", AuteurNom = "Martin" } ]; // Act IReadOnlyList notes = await _service.GetNotesForDocumentAsync(documentId); // Assert Assert.Equal(1, _api.GetNotesByDocumentCalls); DocumentNoteItem note = Assert.Single(notes); Assert.Equal("Note serveur", note.Contenu); Assert.Null(note.SyncLabel); Assert.Equal([noteId], (await NoteDocumentRepository.GetByDocumentAsync(documentId)).Select(n => n.Id)); } /// /// En ligne mais serveur injoignable : l'échec ne remonte plus (le panneau /// notes ne doit jamais casser l'écran) et la copie locale est conservée. /// [Fact] public async Task NotesDocument_EchecReseau_CopieLocaleConservee() { // Arrange : une note locale déjà enregistrée, puis un serveur en échec. Guid documentId = Guid.NewGuid(); await NoteDocumentRepository.UpsertAsync(new LocalNoteDocument { Id = Guid.NewGuid(), DocumentId = documentId, Contenu = "Note locale", DateCreation = new DateTime(2026, 9, 10, 14, 0, 0) }); _api.NotesByDocumentException = new HttpRequestException("Connection failure"); // Act IReadOnlyList notes = await _service.GetNotesForDocumentAsync(documentId); // Assert Assert.Equal(1, _api.GetNotesByDocumentCalls); Assert.Equal("Note locale", Assert.Single(notes).Contenu); } }