Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,698 @@
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;
/// <summary>
/// Tests du service de documents mobile.
/// </summary>
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<MobileDocumentService>.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<PagedResult<Document>> SearchAsync(PagedRequest request, CancellationToken cancellationToken = default)
{
var items = Document is null ? Array.Empty<Document>() : new[] { Document };
return Task.FromResult(new PagedResult<Document>(items, 1, 1000, items.Length, 1));
}
public Task<IReadOnlyList<Document>> GetByInterventionAsync(PhysicalEntityIdentity interventionIdentity, CancellationToken cancellationToken = default)
{
var items = Document is null ? Array.Empty<Document>() : new[] { Document };
return Task.FromResult<IReadOnlyList<Document>>(items);
}
public Task<IReadOnlyList<Document>> GetByInterventionsAsync(IReadOnlyList<long> numericInterventionIds, CancellationToken cancellationToken = default)
{
var items = Document is null ? Array.Empty<Document>() : new[] { Document };
return Task.FromResult<IReadOnlyList<Document>>(items);
}
public Task<Document?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
RequestedId = Guid.TryParse(id, out var g) ? g : null;
return Task.FromResult(Document);
}
public Task<Document?> GetByPhysicalIdentityAsync(PhysicalEntityIdentity identity, CancellationToken cancellationToken = default)
{
RequestedPhysicalGuid = identity.Guid;
return Task.FromResult(Document);
}
public Task<Document> 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<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public string? ResolvedPath { get; set; }
public Task<string?> ResolveDocumentFullPathAsync(Document document, CancellationToken cancellationToken = default)
=> Task.FromResult(ResolvedPath);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(long documentId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public long LastNoteCreationUser { get; private set; }
public Task<NoteInterneDto> AddNoteAsync(long documentId, string contenu, long creationUser, CancellationToken cancellationToken = default)
{
LastNoteCreationUser = creationUser;
return Task.FromResult(new NoteInterneDto());
}
public long ResolvedCreationUser { get; set; }
public Task<long> 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<long> 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<DocumentWriteResult?> WriteDocumentFileAsync(long interventionId, long categorieId, string fileName, byte[] content, CancellationToken cancellationToken = default)
{
WrittenCategorieId = categorieId;
if (WrittenFilePath is null)
{
return Task.FromResult<DocumentWriteResult?>(null);
}
return Task.FromResult<DocumentWriteResult?>(
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);
}
}