using Microsoft.Extensions.Options; using Omika.Core.Configuration; using Omika.Core.Constants; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Configuration; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Organisation; using Omika.Core.Contracts.Planning; using Omika.Core.Contracts.Security; using Omika.Core.Domain.Commands; using Omika.Core.Domain.Entities.Mobile; using Omika.Core.Domain.Entities.Organisation; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Services.Mobile; using Omika.Tests.Mobile.Local; using Omika.Core.Domain; namespace Omika.Tests.Mobile; /// /// Tests du service de bootstrap mobile. /// Vérifie que le même contrat commun est fourni pour Batik et Omika, /// que les droits sont correctement retournés et que le DeviceId est contrôlé. /// public class MobileBootstrapServiceTests { private const string CodeClient = "BATIK.01"; private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"; private const string DeviceId = "device-abc-123"; private const string UserId = "user-1"; private static MobileTokenIdentity Identity(string? deviceId = null, string? collaborateurId = "7") => new() { CodeClient = CodeClient, DeviceId = deviceId ?? DeviceId, UserId = UserId, CollaborateurId = collaborateurId, NumeroLicence = NumeroLicence }; private static Licence ValidLicence(string? deviceId = null) => new() { Numero = NumeroLicence, CodeClient = CodeClient, ClientId = 1, DeviceId = deviceId, Active = true, UserNom = "Dupont", UserPrenom = "Jean", UserId = 42, CollaborateurId = 7, Type = 5 }; private static MobileBootstrapService CreateService( IMobileIdentityProvider identityProvider, ILicenceRepository licenceRepository, ICollaborateurService? collaborateurService = null, int offlineGraceDays = 10, IApplicationParameterRepository? applicationParameterRepository = null, ICollaborateurRepository? collaborateurRepository = null, string? supportEmail = "support@example.test", ISmtpConfigurationService? smtpConfigurationService = null) { var options = Options.Create(new LicenseOptions { OfflineGraceDays = offlineGraceDays }); var support = Options.Create(new SupportOptions { Email = supportEmail ?? string.Empty }); return new MobileBootstrapService( identityProvider, collaborateurService ?? new FakeCollaborateurService(), new FakeEquipeService(), licenceRepository, new FakeParametreMetierRepository(), applicationParameterRepository ?? new FakeApplicationParameterRepository(), collaborateurRepository ?? new FakeCollaborateurRepository(), options, support, smtpConfigurationService); } [Fact] public async Task Bootstrap_Batik_Returns_Common_Profile() { // Arrange var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : profil commun, sans révéler la génération. Assert.NotNull(result); Assert.Equal("BATIK", result.Profil.Code); Assert.Equal(UserId, result.Utilisateur.Id); Assert.Equal("BATIK", result.Utilisateur.NomAffiche); } [Fact] public async Task Bootstrap_Omika_Returns_Common_Profile() { // Arrange var identityProvider = new FakeIdentityProvider("OMIKA"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : profil commun, sans révéler la génération. Assert.NotNull(result); Assert.Equal("OMIKA", result.Profil.Code); Assert.Equal(UserId, result.Utilisateur.Id); } [Fact] public async Task Bootstrap_Returns_Same_Contract_For_Batik_And_Omika() { // Arrange var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var batikService = CreateService(new FakeIdentityProvider("BATIK"), licenceRepo); var omikaService = CreateService(new FakeIdentityProvider("OMIKA"), licenceRepo); // Act var batik = await batikService.GetBootstrapAsync(Identity()); var omika = await omikaService.GetBootstrapAsync(Identity()); // Assert : le même type de contrat public est retourné. Assert.IsType(batik); Assert.IsType(omika); Assert.IsType(batik.Profil); Assert.IsType(omika.Profil); } [Fact] public async Task Bootstrap_RetourneEmailSupportConfig() { // Arrange var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(new FakeIdentityProvider("BATIK"), licenceRepo, supportEmail: "support@example.test"); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : l'email de support est transmis au mobile via la licence. Assert.NotNull(result.Licence); Assert.Equal("support@example.test", result.Licence.SupportEmail); } [Fact] public async Task Bootstrap_SupportEmailVide_RetourneNull() { // Arrange : email de support vide dans la configuration. var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(new FakeIdentityProvider("BATIK"), licenceRepo, supportEmail: " "); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : jamais une chaîne vide transmise au mobile. Assert.NotNull(result.Licence); Assert.Null(result.Licence.SupportEmail); } [Fact] public async Task Bootstrap_Returns_Permissions() { // Arrange var identityProvider = new FakeIdentityProvider("BATIK"); identityProvider.Permissions.Add(new PermissionDto { Cle = "DOC.LIRE", Module = "DOC", Action = "LIRE", Autorisee = true }); identityProvider.Permissions.Add(new PermissionDto { Cle = "DOC.ECRIRE", Module = "DOC", Action = "ECRIRE", Autorisee = false }); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : seules les permissions autorisées sont exposées dans l'utilisateur. Assert.Equal(2, result.Droits.Count); Assert.Contains("DOC.LIRE", result.Utilisateur.Permissions); Assert.DoesNotContain("DOC.ECRIRE", result.Utilisateur.Permissions); } [Fact] public async Task Bootstrap_Returns_RealLogin_Not_UserId() { // Arrange : le login réel est rechargé depuis la base via le provider, // jamais repris tel quel de la claim UserId (identifiant physique). var identityProvider = new FakeIdentityProvider("BATIK") { LoginToReturn = "TEST_USER" }; var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert Assert.Equal("TEST_USER", result.Utilisateur.Login); Assert.Equal(UserId, result.Utilisateur.Id); } [Fact] public async Task Bootstrap_DeviceId_Incompatible_Throws() { // Arrange var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence("autre-device") }; var service = CreateService(identityProvider, licenceRepo); // Act & Assert await Assert.ThrowsAsync( () => service.GetBootstrapAsync(Identity())); } [Fact] public async Task Bootstrap_Resolves_Licence_From_Token() { // Arrange var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : la licence est résolue depuis le token, jamais d'une valeur client. Assert.NotNull(result.Licence); Assert.Equal(NumeroLicence, result.Licence.NumeroLicence); Assert.Equal(CodeClient, result.Licence.CodeClient); Assert.Equal(10, result.Licence.OfflineGraceDays); } [Fact] public async Task Bootstrap_OfflineGraceDays_Comes_From_Configuration() { // Arrange var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService(identityProvider, licenceRepo, offlineGraceDays: 25); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : la durée offline provient de la configuration, pas codée en dur. Assert.Equal(25, result.Configuration.OfflineGraceDays); Assert.Equal(25, result.Licence!.OfflineGraceDays); } [Fact] public async Task Bootstrap_ImageMaxSizeKb_Comes_From_Client_Parameter() { // Arrange : la limite est stockée dans la base du CLIENT (ParametreApplication), // donc identique sur tous ses appareils. var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var parametres = new FakeApplicationParameterRepository(); parametres.Values[PhotoOptimizationSettings.PoidsMaxPhotoKoParameterCode] = "2048"; var service = CreateService( identityProvider, licenceRepo, applicationParameterRepository: parametres); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : 2048 Ko = 2 Mo, exposé au mobile. Assert.Equal(2048, result.Configuration.ImageMaxSizeKb); } [Fact] public async Task Bootstrap_ImageMaxSizeKb_Absent_RetombeSurLaValeurParDefaut() { // Arrange : aucun paramètre en base (client existant, jamais configuré). var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService( identityProvider, licenceRepo, applicationParameterRepository: new FakeApplicationParameterRepository()); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert Assert.Equal(PhotoOptimizationSettings.PoidsMaxPhotoKoParDefaut, result.Configuration.ImageMaxSizeKb); } [Fact] public async Task Bootstrap_ImageMaxSizeKb_Zero_ConserveAucuneLimite() { // Arrange : 0 est un choix explicite du client (« aucune limite ») et ne // doit pas être confondu avec une valeur absente. var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var parametres = new FakeApplicationParameterRepository(); parametres.Values[PhotoOptimizationSettings.PoidsMaxPhotoKoParameterCode] = "0"; var service = CreateService( identityProvider, licenceRepo, applicationParameterRepository: parametres); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert Assert.Equal(0, result.Configuration.ImageMaxSizeKb); } [Fact] public async Task Bootstrap_LanguesUrl_VientDuParametreSysteme() { // Arrange : l'URL est lue en base (ParametreSysteme SRV_LANGUES), jamais en dur. var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var parametres = new FakeCollaborateurRepository(); parametres.ParametreSysteme["SRV_LANGUES"] = "https://langues.example.test"; var service = CreateService( identityProvider, licenceRepo, collaborateurRepository: parametres); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert Assert.Equal("https://langues.example.test", result.Configuration.LanguesUrl); } [Fact] public async Task Bootstrap_LanguesUrl_NormaliseEspacesEtSlashFinal() { // Arrange : l'URL brute peut porter des espaces et un slash final. var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var parametres = new FakeCollaborateurRepository(); parametres.ParametreSysteme["SRV_LANGUES"] = " https://langues.example.test/ "; var service = CreateService( identityProvider, licenceRepo, collaborateurRepository: parametres); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert Assert.Equal("https://langues.example.test", result.Configuration.LanguesUrl); } [Fact] public async Task Bootstrap_LanguesUrl_Absent_RetourneNull() { // Arrange : paramètre système absent (client jamais configuré). var identityProvider = new FakeIdentityProvider("BATIK"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService( identityProvider, licenceRepo, collaborateurRepository: new FakeCollaborateurRepository()); // Act var result = await service.GetBootstrapAsync(Identity()); // Assert : jamais une chaîne vide transmise au mobile. Assert.Null(result.Configuration.LanguesUrl); } // ===== Préférence de messagerie (CHOIX_MESSAGERIE) ===== /// /// La préférence du collaborateur est transmise au mobile : sans elle, /// l'application proposerait un composeur interne à un utilisateur qui envoie /// ses courriels depuis sa propre messagerie. /// [Fact] public async Task Bootstrap_MessagerieExterne_EstTransmise() { var identityProvider = new FakeIdentityProvider("OMIKA"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService( identityProvider, licenceRepo, smtpConfigurationService: new FakeSmtpConfigurationService(messagerieExterne: true)); var result = await service.GetBootstrapAsync(Identity()); Assert.True(result.MessagerieExterne); } /// Messagerie interne : le drapeau reste faux (composeur de l'application). [Fact] public async Task Bootstrap_MessagerieInterne_DrapeauFaux() { var identityProvider = new FakeIdentityProvider("OMIKA"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService( identityProvider, licenceRepo, smtpConfigurationService: new FakeSmtpConfigurationService(messagerieExterne: false)); var result = await service.GetBootstrapAsync(Identity()); Assert.False(result.MessagerieExterne); } /// /// Une lecture de préférence en échec ne bloque JAMAIS le démarrage : le /// bootstrap aboutit avec le comportement interne. /// [Fact] public async Task Bootstrap_PreferenceEnEchec_NeBloquePasLeDemarrage() { var identityProvider = new FakeIdentityProvider("OMIKA"); var licenceRepo = new FakeLicenceRepository { Licence = ValidLicence(DeviceId) }; var service = CreateService( identityProvider, licenceRepo, smtpConfigurationService: new FakeSmtpConfigurationService(exception: true)); var result = await service.GetBootstrapAsync(Identity()); Assert.False(result.MessagerieExterne); Assert.Equal(UserId, result.Utilisateur.Id); } /// Faux service SMTP : seule la préférence de messagerie est sollicitée. private sealed class FakeSmtpConfigurationService : ISmtpConfigurationService { private readonly bool _messagerieExterne; private readonly bool _exception; public FakeSmtpConfigurationService(bool messagerieExterne = false, bool exception = false) { _messagerieExterne = messagerieExterne; _exception = exception; } public bool Appelee { get; private set; } public Task> GetListAsync( string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task GetAsync( string collaborateurId, string configurationId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task EstMessagerieExterneAsync( string collaborateurId, CancellationToken cancellationToken = default) { Appelee = true; return _exception ? throw new InvalidOperationException("lecture de la préférence impossible") : Task.FromResult(_messagerieExterne); } } // ===== Fakes ===== private sealed class FakeIdentityProvider : IMobileIdentityProvider { private readonly string _profileCode; public FakeIdentityProvider(string profileCode) { _profileCode = profileCode; } public List Permissions { get; } = []; public string? LoginToReturn { get; set; } public Task GetProfileAsync(string userId, CancellationToken cancellationToken = default) => Task.FromResult(new MobileProfileDto { Code = _profileCode, Nom = _profileCode, Description = _profileCode, RoleSysteme = false }); public Task> GetPermissionsAsync(string userId, CancellationToken cancellationToken = default) => Task.FromResult>( Permissions.ToDictionary(p => p.Cle, p => p.Autorisee)); public Task GetLoginAsync(string userId, CancellationToken cancellationToken = default) => Task.FromResult(LoginToReturn); } private sealed class FakeLicenceRepository : ILicenceRepository { public Licence? Licence { get; set; } public Task GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetByNumeroAsync(string numero, CancellationToken cancellationToken = default) => Task.FromResult(Licence); public Task GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(Licence); public Task GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeCollaborateurRepository : ICollaborateurRepository { public Dictionary ParametreSysteme { get; } = []; public Task GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default) => Task.FromResult(ParametreSysteme.TryGetValue(code, out string? value) ? value : null); public Task> SearchAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task> SearchDetailsAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task GetDetailsAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetUtilisateurByCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task CreateAsync(CollaborateurCreateCommand command, CancellationToken cancellationToken = default) => Task.FromResult(new Collaborateur()); public Task UpdateAsync(string id, CollaborateurUpdateCommand command, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(true); } private sealed class FakeCollaborateurService : ICollaborateurService { public Task> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(new CollaborateurDetailsDto { Id = id }); public Task CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => Task.FromResult(new CollaborateurDetailsDto()); public Task UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => Task.FromResult(new CollaborateurDetailsDto()); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetRendezVousForCollaborateursAsync(IReadOnlyList collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeParametreMetierRepository : IParametreMetierRepository { public Task> GetAllAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeEquipeService : IEquipeService { public Task> GetEquipesForCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetAllEquipesAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); } }