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>([]);
}
}