using System.Net.Http; using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Organisation; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel de la fiche Collaborateur : chargement local, état /// introuvable, coordonnées, appels/emails et navigation. Les rendez-vous et /// interventions ne sont plus chargés ni affichés dans cette fiche. /// /// Couvre aussi la politique d'erreur des actions de licence : un échec réseau — /// ticket de production du 14/10, « Connection failure » sur l'attribution d'une /// licence mobile — est un état dégradé journalisé en Warning/P2 avec un message /// actionnable, jamais un incident Error/P1. Les erreurs métier, elles, restent /// des incidents. /// [Collection("TelemetrieStatique")] public class CollaborateurDetailViewModelTests : LocalTestBase { private sealed class FakePhoneLauncher : IPhoneLauncher { public int DialCalls { get; private set; } public string? LastNumber { get; private set; } public Task DialAsync(string phoneNumber) { DialCalls++; LastNumber = phoneNumber; return Task.FromResult(true); } } private sealed class FakeEmailLauncher : IEmailLauncher { public int ComposeCalls { get; private set; } public string? LastEmail { get; private set; } public bool ComposeResult { get; set; } = true; public Task ComposeAsync(string email) { ComposeCalls++; LastEmail = email; return Task.FromResult(ComposeResult); } } private readonly CollaborateurDetailViewModel _viewModel; private readonly NavigationService _navigation; private readonly FakePhoneLauncher _phoneLauncher; private readonly FakeEmailLauncher _emailLauncher; public CollaborateurDetailViewModelTests() { var service = new CollaborateurLocalService(CollaborateurRepository, RendezVousRepository, ContextInterventionCacheRepository, ApiClient, SessionService); _navigation = new NavigationService(); _phoneLauncher = new FakePhoneLauncher(); _emailLauncher = new FakeEmailLauncher(); _viewModel = new CollaborateurDetailViewModel(service, _phoneLauncher, _emailLauncher, _navigation); } private static LocalCollaborateur CreateCollaborateur( Guid id, string? nom = null, string? prenom = null, string? code = null, string? statut = null, string? telephone = null, string? mobile = null, string? email = null) { var dto = new CollaborateurDetailsDto { Id = id.ToString(), Nom = nom, Prenom = prenom, Code = code, Statut = statut is null ? null : new ReferenceValueDto { Label = statut }, Coordonnees = telephone is null && mobile is null && email is null ? null : new ContactDetailsDto { Phone = telephone, Mobile = mobile, Email = email } }; return new LocalCollaborateur { Id = id, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task Load_ChargementPuisDetail() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean", "C001")); // Act await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Assert : après chargement, détail affiché et chargement terminé. Assert.False(_viewModel.IsLoading); Assert.NotNull(_viewModel.Collaborateur); Assert.Equal("Dupont", _viewModel.Collaborateur.Nom); Assert.Equal("Jean", _viewModel.Collaborateur.Prenom); } [Fact] public async Task Load_CollaborateurIntrouvable_MessageErreur() { // Act await _viewModel.LoadCommand.ExecuteAsync(Guid.NewGuid()); // Assert Assert.False(_viewModel.IsLoading); Assert.NotNull(_viewModel.ErrorMessage); Assert.Null(_viewModel.Collaborateur); Assert.True(_viewModel.IsNotFound); } [Fact] public async Task Load_CoordonneesAffichees() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur( collaborateurId, "Dupont", "Jean", "C001", "Actif", telephone: "0102030405", mobile: "0607080910", email: "jean@omika.fr")); // Act await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Assert Assert.True(_viewModel.HasCoordonnees); Assert.Equal("0102030405", _viewModel.Collaborateur!.Phone); Assert.Equal("0607080910", _viewModel.Collaborateur.Mobile); Assert.Equal("jean@omika.fr", _viewModel.Collaborateur.Email); } [Fact] public async Task Load_ChampsOptionnelsAbsents() { // Arrange : collaborateur minimal sans coordonnées. Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); // Act await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Assert : champs optionnels absents restent nuls sans erreur. Assert.NotNull(_viewModel.Collaborateur); Assert.False(_viewModel.HasCoordonnees); Assert.Null(_viewModel.Collaborateur.Phone); Assert.Null(_viewModel.Collaborateur.Email); } [Fact] public async Task Load_IdentitePublique_Conservee() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); // Act await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Assert Assert.NotNull(_viewModel.Collaborateur); Assert.Equal(collaborateurId, _viewModel.Collaborateur.CollaborateurGuid); } [Fact] public async Task Call_NumeroDisponible_LanceAppel() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean", telephone: "0102030405")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Act await _viewModel.CallCommand.ExecuteAsync(null); // Assert Assert.Equal(1, _phoneLauncher.DialCalls); Assert.Equal("0102030405", _phoneLauncher.LastNumber); } [Fact] public async Task Call_SansNumero_NeLancePas() { // Arrange : collaborateur sans téléphone. Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Act await _viewModel.CallCommand.ExecuteAsync(null); // Assert : aucun appel lancé. Assert.Equal(0, _phoneLauncher.DialCalls); } [Fact] public async Task Email_AdresseDisponible_LanceCompose() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean", email: "jean@omika.fr")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Act await _viewModel.EmailCommand.ExecuteAsync(null); // Assert Assert.Equal(1, _emailLauncher.ComposeCalls); Assert.Equal("jean@omika.fr", _emailLauncher.LastEmail); } [Fact] public async Task Email_SansAdresse_NeLancePas() { // Arrange : collaborateur sans email. Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Act await _viewModel.EmailCommand.ExecuteAsync(null); // Assert : aucun email lancé. Assert.Equal(0, _emailLauncher.ComposeCalls); } [Fact] public async Task Email_LauncherRetourneFalse_MessageErreur() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean", email: "jean@omika.fr")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); _emailLauncher.ComposeResult = false; // Act await _viewModel.EmailCommand.ExecuteAsync(null); // Assert : le résultat false est remonté à l'utilisateur. Assert.Equal(1, _emailLauncher.ComposeCalls); Assert.Equal("Aucune application de messagerie disponible.", _viewModel.ErrorMessage); } [Fact] public async Task Email_LauncherRetourneTrue_AucunMessage() { // Arrange Guid collaborateurId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean", email: "jean@omika.fr")); await _viewModel.LoadCommand.ExecuteAsync(collaborateurId); // Act await _viewModel.EmailCommand.ExecuteAsync(null); // Assert : succès → aucun message d'erreur. Assert.Equal(1, _emailLauncher.ComposeCalls); Assert.Null(_viewModel.ErrorMessage); } [Fact] public async Task Back_SansContexte_RetourneALaListe() { // Act _viewModel.BackCommand.Execute(null); // Assert Assert.Equal(AppPage.Collaborateurs, _navigation.CurrentPage); } [Fact] public async Task Back_AvecContexte_RetourneALaPageOrigine() { // Arrange : chargement via DetailNavigation + historique (Clients -> CollaborateurDetail). Guid collaborateurId = Guid.NewGuid(); Guid clientId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); var nav = new DetailNavigation(collaborateurId, new NavigationContext(AppPage.Clients, clientId)); _navigation.NavigateTo(AppPage.Clients, clientId); _navigation.NavigateTo(AppPage.CollaborateurDetail, nav); await _viewModel.LoadCommand.ExecuteAsync(nav); // Act _viewModel.BackCommand.Execute(null); // Assert : retour vers la page précédente (Clients) avec son paramètre. Assert.Equal(AppPage.Clients, _navigation.CurrentPage); Assert.Equal(clientId, _navigation.CurrentParameter); } [Fact] public async Task Load_DetailNavigation_ChargeEtRetourPrecedent() { // Arrange Guid collaborateurId = Guid.NewGuid(); Guid clientId = Guid.NewGuid(); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); var nav = new DetailNavigation(collaborateurId, new NavigationContext(AppPage.Clients, clientId)); // Construit l'historique : Clients -> CollaborateurDetail. _navigation.NavigateTo(AppPage.Clients, clientId); _navigation.NavigateTo(AppPage.CollaborateurDetail, nav); // Act await _viewModel.LoadCommand.ExecuteAsync(nav); // Assert : le collaborateur est chargé. Assert.NotNull(_viewModel.Collaborateur); Assert.Equal(collaborateurId, _viewModel.Collaborateur.CollaborateurGuid); // Le retour ramène à la page précédente. _viewModel.BackCommand.Execute(null); Assert.Equal(AppPage.Clients, _navigation.CurrentPage); Assert.Equal(clientId, _navigation.CurrentParameter); } // ===== Politique d'erreur des actions de licence (ticket de production du 14/10) ===== /// /// Ticket de production du 14/10 07:55 UTC (component CollaborateurDetailViewModel, /// « Connection failure » remontée par AndroidMessageHandler.ConnectAsync pendant /// l'attribution d'une licence mobile) : une coupure réseau n'est pas un incident. /// L'événement part en Warning/P2, l'écran n'entre dans aucun groupe d'erreurs, et /// l'utilisateur reçoit un message actionnable — sans promesse fausse sur l'issue, /// puisque le code ne peut pas distinguer « rien n'est parti » d'« réponse perdue ». /// [Fact] public async Task AssignLicence_EchecReseau_SignaleEnWarningSansIncident() { var service = new FakeCollaborateurService { Detail = CreateDetail() }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); await viewModel.LoadCommand.ExecuteAsync(service.Detail!.CollaborateurGuid); Assert.NotNull(viewModel.Collaborateur); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.ExceptionOnAssign = new HttpRequestException("Connection failure"); await viewModel.AssignLicenceCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal( "Serveur injoignable — l'opération n'a pas abouti. Réessayez une fois le réseau revenu : un nouvel essai est sans risque.", viewModel.LicenceMessage); Assert.False(viewModel.IsUpdatingLicence); AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementCollaborateur(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); Assert.StartsWith("Connection failure", evt.Message); } /// /// Aucune URL d'API configurée (appareil non inscrit) : ce n'est PAS une coupure /// réseau — l'appareil peut être en ligne. On affiche l'action à faire (« rescannez /// le QR ») et jamais « hors ligne », sans incident P1. /// [Fact] public async Task AssignLicence_SansUrlDApi_MessageActionnableSansMentionHorsLigne() { var service = new FakeCollaborateurService { Detail = CreateDetail() }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); await viewModel.LoadCommand.ExecuteAsync(service.Detail!.CollaborateurGuid); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.ExceptionOnAssign = new MobileEnrollmentRequiredException(); await viewModel.AssignLicenceCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal( "Inscription incomplète — rescannez le QR code pour reconnecter l'application.", viewModel.LicenceMessage); Assert.DoesNotContain("Hors ligne", viewModel.LicenceMessage); AssertAucunIncident(telemetry); Assert.Equal(TelemetryLevel.Warning, EvenementCollaborateur(telemetry).Level); } /// /// Contre-épreuve du classement : une erreur MÉTIER reste un incident Error/P1. /// On ne dégrade pas les vraies erreurs en Warning sous prétexte qu'elles /// ressemblent à un échec d'appel. /// [Fact] public async Task AssignLicence_ErreurMetier_ResteEnIncident() { var service = new FakeCollaborateurService { Detail = CreateDetail() }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); await viewModel.LoadCommand.ExecuteAsync(service.Detail!.CollaborateurGuid); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.ExceptionOnAssign = new InvalidOperationException("Aucune licence mobile libre pour ce client."); await viewModel.AssignLicenceCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal( "Erreur lors de l'attribution : Aucune licence mobile libre pour ce client.", viewModel.LicenceMessage); DiagnosticEvent evt = EvenementCollaborateur(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// Même classement sur le retrait de licence (même écran, même nature d'échec). [Fact] public async Task ReleaseLicence_EchecReseau_SignaleEnWarningSansIncident() { var service = new FakeCollaborateurService { Detail = CreateDetail() }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); await viewModel.LoadCommand.ExecuteAsync(service.Detail!.CollaborateurGuid); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.ExceptionOnRelease = new HttpRequestException("Connection failure"); await viewModel.ReleaseLicenceCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal( "Serveur injoignable — l'opération n'a pas abouti. Réessayez une fois le réseau revenu : un nouvel essai est sans risque.", viewModel.LicenceMessage); Assert.False(viewModel.IsUpdatingLicence); AssertAucunIncident(telemetry); Assert.Equal(TelemetryLevel.Warning, EvenementCollaborateur(telemetry).Level); } /// /// Troisième site classé : un échec réseau remonté par le chargement de la fiche /// n'est pas non plus un incident. (« Défensif » : le service local absorbe déjà /// les erreurs réseau de la fiche, donc ce chemin ne voit aujourd'hui que des /// erreurs locales — qui, elles, doivent rester des incidents.) /// [Fact] public async Task Load_EchecReseau_SignaleEnWarningSansIncident() { var service = new FakeCollaborateurService { ExceptionOnLoad = new HttpRequestException("Connection failure") }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await viewModel.LoadCommand.ExecuteAsync(Guid.NewGuid()); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("Hors ligne — impossible de contacter le serveur.", viewModel.ErrorMessage); Assert.False(viewModel.IsLoading); AssertAucunIncident(telemetry); Assert.Equal(TelemetryLevel.Warning, EvenementCollaborateur(telemetry).Level); } /// /// L'issue de l'appel est inconnue et le réessai est sans risque (attribution /// idempotente côté serveur : une licence déjà attribuée est retournée telle /// quelle, sans consommer de licence libre). Deux clics produisent donc bien deux /// tentatives, sans modifier l'état local ni créer de doublon. /// [Fact] public async Task AssignLicence_EchecReseau_DeuxEssais_DeuxTentativesSansEtatLocal() { var service = new FakeCollaborateurService { Detail = CreateDetail() }; CollaborateurDetailViewModel viewModel = CreateViewModel(service); await viewModel.LoadCommand.ExecuteAsync(service.Detail!.CollaborateurGuid); CollaborateurDetail? avant = viewModel.Collaborateur; var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.ExceptionOnAssign = new HttpRequestException("Connection failure"); await viewModel.AssignLicenceCommand.ExecuteAsync(null); await viewModel.AssignLicenceCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal(2, service.AssignCalls); Assert.Same(avant, viewModel.Collaborateur); Assert.False(viewModel.IsUpdatingLicence); AssertAucunIncident(telemetry); } private CollaborateurDetailViewModel CreateViewModel(ICollaborateurLocalService service) => new(service, _phoneLauncher, _emailLauncher, _navigation); private static CollaborateurDetail CreateDetail() => new() { CollaborateurGuid = Guid.NewGuid(), Nom = "Dupont", Prenom = "Jean", NomAffiche = "Dupont Jean" }; /// /// Seul événement de diagnostic attribué au ViewModel : la télémétrie d'usage /// (fonctionnalités) est un , pas un /// . Le filtre porte sur le composant, la télémétrie /// statique globale étant partagée avec les classes exécutées en parallèle. /// private static DiagnosticEvent EvenementCollaborateur(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(CollaborateurDetailViewModel)); /// Aucun événement de diagnostic de cet écran en Error/Critical. private static void AssertAucunIncident(CapturingTelemetry telemetry) => Assert.DoesNotContain( telemetry.Events.OfType(), e => e.Component == nameof(CollaborateurDetailViewModel) && e.Level == TelemetryLevel.Error); /// /// Capture en mémoire des événements de la télémétrie statique globale /// (), rétablie à null après /// chaque test — la classe appartient à la collection « TelemetrieStatique ». /// private sealed class CapturingTelemetry : IOmikaTelemetry { public List Events { get; } = []; public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent); public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent); } /// /// Service collaborateur piloté par le test : reproduit l'échec réseau du ticket de /// production du 14/10 (« Connection failure ») sur les commandes de licence. /// /// L'interface est réimplémentée explicitement (: ICollaborateurLocalService) : /// les méthodes de licence sont des implémentations PAR DÉFAUT, qu'une simple /// redéclaration sans réimplémentation d'interface ne remplacerait pas — la version /// par défaut s'exécuterait et le test serait vide (il passerait sans rien vérifier). /// private sealed class FakeCollaborateurService : ICollaborateurLocalService { public CollaborateurDetail? Detail { get; set; } public Exception? ExceptionOnLoad { get; set; } public Exception? ExceptionOnAssign { get; set; } public Exception? ExceptionOnRelease { get; set; } public int AssignCalls { get; private set; } public int ReleaseCalls { get; private set; } public Task> GetCollaborateursAsync( CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task GetCollaborateurByIdAsync( Guid collaborateurGuid, CancellationToken cancellationToken = default) => ExceptionOnLoad is { } ex ? Task.FromException(ex) : Task.FromResult(Detail); public Task AssignMobileLicenceAsync( Guid collaborateurGuid, CancellationToken cancellationToken = default) { AssignCalls++; return ExceptionOnAssign is { } ex ? Task.FromException(ex) : Task.FromResult(null); } public Task ReleaseMobileLicenceAsync( Guid collaborateurGuid, CancellationToken cancellationToken = default) { ReleaseCalls++; return ExceptionOnRelease is { } ex ? Task.FromException(ex) : Task.FromResult(false); } } }