using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Omika.Core.Compatibility; using Omika.Core.Configuration; using Omika.Core.Constants; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Documents; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Domain.Entities.Documents; using Omika.Core.Domain.ValueObjects; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Services.Mobile; using Omika.Core.Domain; namespace Omika.Tests.Mobile; /// /// Tests du service de documents mobile. /// public class MobileDocumentServiceTests { private static Document ValidDocument(Guid id) { return new Document { Id = id, Nom = "rapport.pdf", MimeType = "application/pdf", Extension = ".pdf", TailleOctets = 1024, CheminLogique = "mobile/abc", DateCreation = DateTime.Now }; } private static MobileDocumentService CreateService( FakeDocumentRepository repo, FakeFileStorage? fileStorage = null, IMobileEntityIdentityResolver? resolver = null, DocumentStorageOptions? storageOptions = null) => new( repo, resolver ?? new FakeIdentityResolver(), fileStorage ?? new FakeFileStorage(), Options.Create(storageOptions ?? new DocumentStorageOptions { MaxUploadSizeBytes = 1024 * 1024 }), NullLogger.Instance); [Fact] public async Task Download_Document_Trouve_Returns_Metadata() { // Arrange var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { Document = ValidDocument(guid) }; var service = CreateService(repo); // Act var result = await service.DownloadAsync(guid); // Assert Assert.NotNull(result); Assert.Equal(guid, result!.DocumentGuid); Assert.Equal("rapport.pdf", result.NomFichier); Assert.Equal("application/pdf", result.MimeType); } [Fact] public async Task Download_Document_Inconnu_Returns_Null() { // Arrange var repo = new FakeDocumentRepository(); var service = CreateService(repo); // Act var result = await service.DownloadAsync(Guid.NewGuid()); // Assert Assert.Null(result); } [Fact] public async Task Download_Empty_Guid_Returns_Null() { // Arrange var repo = new FakeDocumentRepository(); var service = CreateService(repo); // Act var result = await service.DownloadAsync(Guid.Empty); // Assert Assert.Null(result); } [Fact] public async Task Download_Returns_Binary_Content() { // Arrange var guid = Guid.NewGuid(); var content = new byte[] { 1, 2, 3, 4, 5 }; string tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"omika-test-{guid:N}.bin"); await System.IO.File.WriteAllBytesAsync(tempPath, content); var repo = new FakeDocumentRepository { Document = ValidDocument(guid), ResolvedPath = tempPath }; var service = CreateService(repo); // Act var result = await service.DownloadAsync(guid); // Assert : le contenu binaire est lu depuis le chemin physique résolu. Assert.NotNull(result); Assert.Equal(5, result!.TailleOctets); Assert.Equal(content, result.Contenu); Assert.True(result.Existe); System.IO.File.Delete(tempPath); } [Fact] public async Task Download_Resolves_By_Physical_Identity() { // Arrange var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { Document = ValidDocument(guid) }; var service = CreateService(repo); // Act var result = await service.DownloadAsync(guid); // Assert : la résolution passe par l'identité physique (GUID). Assert.NotNull(result); Assert.Equal(guid, repo.RequestedPhysicalGuid); } [Fact] public async Task Upload_Single_Document_Returns_Success() { // Arrange var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Intervention 1/Photos #1" }; var fileStorage = new FakeFileStorage(); var service = CreateService(repo, fileStorage); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "photo.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3, 4 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, new AuditActor(null, "collab-1")); // Assert Assert.True(result.Success); Assert.Equal(guid, result.DocumentGuid); Assert.True(repo.Created); Assert.NotNull(repo.CreatedDocument); Assert.Equal(MobilePermissionKeys.DocCategoriePhoto, repo.CreatedDocument!.Categorie?.Key); Assert.Equal("Intervention 1/Photos #1", repo.CreatedDocument.CheminLogique); } [Fact] public async Task Upload_CheminServeurIndisponible_RepliSurStockage() { // Arrange : le répertoire serveur décodé n'est pas résolvable (racine non // configurée) → repli sur le stockage historique, le document est créé. var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository(); // WrittenFilePath = null var fileStorage = new FakeFileStorage(); var service = CreateService(repo, fileStorage); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "photo.png", MimeType = "image/png", Extension = ".png", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert : succès malgré l'absence de chemin serveur, fichier écrit en repli. Assert.True(result.Success); Assert.True(fileStorage.Written); Assert.NotNull(repo.CreatedDocument); Assert.Equal(MobilePermissionKeys.DocCategoriePhoto, repo.CreatedDocument!.Categorie?.Key); Assert.Equal("mobile/abc", repo.CreatedDocument.CheminLogique); } [Fact] public async Task Upload_Preserves_Provided_Guid() { // Arrange var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Intervention 1/Autre document #2" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "doc.txt", Categorie = MobilePermissionKeys.DocCategorieAutre, Contenu = new byte[] { 1 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategorieAutre, null); // Assert : le serveur conserve le GUID fourni par le mobile. Assert.True(result.Success); Assert.Equal(guid, result.DocumentGuid); Assert.Equal(guid, repo.CreatedDocument!.Id); } [Fact] public async Task Upload_Empty_Guid_Returns_Error() { // Arrange var repo = new FakeDocumentRepository(); var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = Guid.Empty, NomFichier = "doc.txt", Contenu = new byte[] { 1 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.False(result.Success); Assert.Equal("DOCUMENT_GUID_INVALID", result.ErrorCode); } [Fact] public async Task Upload_Invalid_FileName_Returns_Error() { // Arrange var repo = new FakeDocumentRepository(); var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = Guid.NewGuid(), NomFichier = " ", Contenu = new byte[] { 1 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.False(result.Success); Assert.Equal("DOCUMENT_FILENAME_INVALID", result.ErrorCode); } [Fact] public async Task Upload_Write_Error_Returns_Failure() { // Arrange var repo = new FakeDocumentRepository { ThrowOnCreate = true, WrittenFilePath = "Intervention 1/Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = Guid.NewGuid(), NomFichier = "doc.txt", Contenu = new byte[] { 1 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert : erreur d'écriture disque → échec. Assert.False(result.Success); Assert.Equal("DOCUMENT_UPLOAD_FAILED", result.ErrorCode); } [Fact] public async Task Upload_Size_Exceeded_Returns_Error() { // Arrange var repo = new FakeDocumentRepository(); var service = CreateService( repo, storageOptions: new DocumentStorageOptions { MaxUploadSizeBytes = 2 }); var request = new DocumentUploadRequest { DocumentGuid = Guid.NewGuid(), NomFichier = "doc.txt", Contenu = new byte[] { 1, 2, 3 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert : taille maximale dépassée → échec. Assert.False(result.Success); Assert.Equal("DOCUMENT_SIZE_EXCEEDED", result.ErrorCode); } // ===== Fakes ===== private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver { public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical) => physical.Guid ?? Guid.NewGuid(); public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid) => publicGuid == Guid.Empty ? null : new PhysicalEntityIdentity { Guid = publicGuid }; public bool IsCanonical(string entityType, Guid publicGuid) => publicGuid != Guid.Empty; } private sealed class FakeFileStorage : IDocumentFileStorage { public byte[]? Content { get; set; } public bool Written { get; private set; } public bool ThrowOnSizeExceeded { get; set; } public byte[]? Read(string? relativeDirectory, string? fileName) => Content; public string Write(string fileName, byte[] content) { if (ThrowOnSizeExceeded) { throw new InvalidOperationException("Taille maximale dépassée."); } Written = true; return "mobile/abc"; } public string? SanitizeFileName(string? fileName) => string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(); } private sealed class FakeDocumentRepository : IDocumentRepository { public Document? Document { get; set; } public bool Created { get; private set; } public Document? CreatedDocument { get; private set; } public bool ThrowOnCreate { get; set; } public Guid? RequestedId { get; private set; } public Guid? RequestedPhysicalGuid { get; private set; } public Task> SearchAsync(PagedRequest request, CancellationToken cancellationToken = default) { var items = Document is null ? Array.Empty() : new[] { Document }; return Task.FromResult(new PagedResult(items, 1, 1000, items.Length, 1)); } public Task> GetByInterventionAsync(PhysicalEntityIdentity interventionIdentity, CancellationToken cancellationToken = default) { var items = Document is null ? Array.Empty() : new[] { Document }; return Task.FromResult>(items); } public Task> GetByInterventionsAsync(IReadOnlyList numericInterventionIds, CancellationToken cancellationToken = default) { var items = Document is null ? Array.Empty() : new[] { Document }; return Task.FromResult>(items); } public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) { RequestedId = Guid.TryParse(id, out var g) ? g : null; return Task.FromResult(Document); } public Task GetByPhysicalIdentityAsync(PhysicalEntityIdentity identity, CancellationToken cancellationToken = default) { RequestedPhysicalGuid = identity.Guid; return Task.FromResult(Document); } public Task CreateAsync(Document document, CancellationToken cancellationToken = default) { if (ThrowOnCreate) { throw new IOException("Erreur d'écriture disque simulée."); } Created = true; CreatedDocument = document; return Task.FromResult(document); } public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(true); public string? ResolvedPath { get; set; } public Task ResolveDocumentFullPathAsync(Document document, CancellationToken cancellationToken = default) => Task.FromResult(ResolvedPath); public Task> GetNotesAsync(long documentId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public long LastNoteCreationUser { get; private set; } public Task AddNoteAsync(long documentId, string contenu, long creationUser, CancellationToken cancellationToken = default) { LastNoteCreationUser = creationUser; return Task.FromResult(new NoteInterneDto()); } public long ResolvedCreationUser { get; set; } public Task ResolveCreationUserAsync(AuditActor? auteur, CancellationToken cancellationToken = default) { // Simule le résolveur central : UserId prioritaire, sinon valeur fixe. if (long.TryParse(auteur?.UserId, out long utPk) && utPk > 0) { return Task.FromResult(utPk); } return Task.FromResult(ResolvedCreationUser); } public long ResolvedCategoryId { get; set; } public string? ResolvedCategoryCle { get; private set; } public Task ResolveCategoryIdAsync(string categorieCle, CancellationToken cancellationToken = default) { ResolvedCategoryCle = categorieCle; return Task.FromResult(ResolvedCategoryId); } public string? WrittenFilePath { get; set; } public string? WrittenFileName { get; set; } public long WrittenCategorieId { get; private set; } public Task WriteDocumentFileAsync(long interventionId, long categorieId, string fileName, byte[] content, CancellationToken cancellationToken = default) { WrittenCategorieId = categorieId; if (WrittenFilePath is null) { return Task.FromResult(null); } return Task.FromResult( new DocumentWriteResult(WrittenFilePath, WrittenFileName ?? fileName)); } } // ============================ Date métier du document ============================ [Fact] public async Task Upload_DateFournieParLAppareil_EstEnregistreeTelleQuelle() { // Arrange : photo prise le 10/09/2026 à 14:32, synchronisée plus tard. Le // serveur ne doit PAS substituer l'instant de l'upload. var priseDeVue = new DateTime(2026, 9, 10, 14, 32, 18); var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "IMG_4521.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 }, DateCreation = priseDeVue }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.True(result.Success); Assert.NotNull(repo.CreatedDocument); Assert.Equal(priseDeVue, repo.CreatedDocument!.DateCreation); } [Fact] public async Task Upload_SansDate_UtiliseLaDateCouranteDuServeur() { // Arrange : client antérieur à la fonctionnalité, ou opération Outbox // historique : le comportement d'origine est conservé. var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "photo.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 } }; DateTime avant = DateTime.Now.AddSeconds(-1); // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.NotNull(repo.CreatedDocument); Assert.InRange(repo.CreatedDocument!.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task Upload_DateDansLeFutur_EstRejeteeAuProfitDeLaDateServeur() { // Arrange : horloge d'appareil déréglée ou client défaillant. Une date // fausse corromprait les tris : on préfère la date serveur. var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "photo.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 }, DateCreation = DateTime.Now.AddYears(10) }; DateTime avant = DateTime.Now.AddSeconds(-1); // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert : repli sur la date serveur, l'upload n'échoue pas pour autant. Assert.NotNull(repo.CreatedDocument); Assert.InRange(repo.CreatedDocument!.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task Upload_DateAberrante_EstRejeteeAuProfitDeLaDateServeur() { // Arrange : valeur sentinelle (métadonnée illisible). var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "photo.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 }, DateCreation = DateTime.MinValue }; DateTime avant = DateTime.Now.AddSeconds(-1); // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.NotNull(repo.CreatedDocument); Assert.InRange(repo.CreatedDocument!.DateCreation, avant, DateTime.Now.AddSeconds(5)); } [Fact] public async Task Upload_DateAncienne_MaisValide_EstConservee() { // Arrange : photo prise en 2019, ajoutée aujourd'hui. C'est le cas métier // central : la date ancienne doit survivre. var ancienne = new DateTime(2019, 5, 14, 8, 3, 0); var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Photos #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "IMG_001.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = MobilePermissionKeys.DocCategoriePhoto, Contenu = new byte[] { 1, 2, 3 }, DateCreation = ancienne }; // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategoriePhoto, null); // Assert Assert.NotNull(repo.CreatedDocument); Assert.Equal(ancienne, repo.CreatedDocument!.DateCreation); } [Fact] public async Task Upload_ServeurRenomme_UtiliseNomFinalEnBase() { // Arrange : le serveur a rendu le nom unique (collision) → Viamichelin_1.pdf. var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Intervention #1", WrittenFileName = "Viamichelin_1.pdf" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "Viamichelin.pdf", MimeType = "application/pdf", Extension = ".pdf", Categorie = MobilePermissionKeys.DocCategorieAutre, Contenu = new byte[] { 1, 2, 3 } }; // Act var result = await service.UploadAsync(request, MobilePermissionKeys.DocCategorieAutre, null); // Assert : le nom en base est EXACTEMENT le nom physique final, et le // titre (affichage mobile) reprend ce même nom canonique. Assert.True(result.Success); Assert.NotNull(repo.CreatedDocument); Assert.Equal("Viamichelin_1.pdf", repo.CreatedDocument!.Nom); Assert.Equal("Viamichelin_1.pdf", repo.CreatedDocument!.Titre); } [Fact] public async Task Upload_SansRenommage_ConserveNomOriginalEnBase() { // Arrange : aucune collision → le nom original est conservé tel quel. var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Intervention #1", WrittenFileName = "Viamichelin.pdf" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "Viamichelin.pdf", MimeType = "application/pdf", Extension = ".pdf", Categorie = MobilePermissionKeys.DocCategorieAutre, Contenu = new byte[] { 1, 2, 3 } }; // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategorieAutre, null); // Assert Assert.NotNull(repo.CreatedDocument); Assert.Equal("Viamichelin.pdf", repo.CreatedDocument!.Nom); } [Fact] public async Task Upload_UserIdPrioritaire_EstUtilisePourCreationUser() { // Arrange : UserId = 57 (UT_PKID), CollaborateurId = 123 (CO_PKID différent). var guid = Guid.NewGuid(); var repo = new FakeDocumentRepository { WrittenFilePath = "Intervention #1" }; var service = CreateService(repo); var request = new DocumentUploadRequest { DocumentGuid = guid, NomFichier = "doc.pdf", MimeType = "application/pdf", Extension = ".pdf", Categorie = MobilePermissionKeys.DocCategorieAutre, Contenu = new byte[] { 1, 2, 3 } }; // Act await service.UploadAsync(request, MobilePermissionKeys.DocCategorieAutre, new AuditActor("57", "123")); // Assert : CreationUser = 57 (UT_PKID), JAMAIS 123 (CO_PKID). Assert.NotNull(repo.CreatedDocument); Assert.Equal(57, repo.CreatedDocument!.CreationUserNumericId); } [Fact] public async Task AddNote_UserIdPrioritaire_EstUtilisePourCreationUser() { // Arrange : le document existe physiquement (SourceReference = PK numérique). var guid = Guid.NewGuid(); var doc = ValidDocument(guid); doc.SourceReference = LegacyEntityReference.FromLegacyId(123); var repo = new FakeDocumentRepository { Document = doc }; var service = CreateService(repo); // Act await service.AddNoteAsync(guid, "note", new AuditActor("57", "123")); // Assert : la note est créée avec CreationUser = 57 (UT_PKID, jamais CO_PKID). Assert.Equal(57, repo.LastNoteCreationUser); } }