Files
Omika/tests/Omika.Tests/Mobile/Local/CollaborateurDetailViewModelTests.cs
2026-10-23 18:46:50 +02:00

626 lines
24 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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<bool> 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<bool> 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) =====
/// <summary>
/// Ticket de production du 14/10 07:55 UTC (component <c>CollaborateurDetailViewModel</c>,
/// « Connection failure » remontée par <c>AndroidMessageHandler.ConnectAsync</c> 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 ».
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>Même classement sur le retrait de licence (même écran, même nature d'échec).</summary>
[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);
}
/// <summary>
/// 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.)
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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"
};
/// <summary>
/// Seul événement de diagnostic attribué au ViewModel : la télémétrie d'usage
/// (fonctionnalités) est un <see cref="TelemetryEvent"/>, pas un
/// <see cref="DiagnosticEvent"/>. 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.
/// </summary>
private static DiagnosticEvent EvenementCollaborateur(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(CollaborateurDetailViewModel));
/// <summary>Aucun événement de diagnostic de cet écran en Error/Critical.</summary>
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(CollaborateurDetailViewModel) && e.Level == TelemetryLevel.Error);
/// <summary>
/// Capture en mémoire des événements de la télémétrie statique globale
/// (<see cref="ExceptionTelemetry.Initialize"/>), rétablie à <c>null</c> après
/// chaque test — la classe appartient à la collection « TelemetrieStatique ».
/// </summary>
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
/// <summary>
/// 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 (<c>: ICollaborateurLocalService</c>) :
/// 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).
/// </summary>
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<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<CollaborateurListItem>>([]);
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(
Guid collaborateurGuid,
CancellationToken cancellationToken = default)
=> ExceptionOnLoad is { } ex
? Task.FromException<CollaborateurDetail?>(ex)
: Task.FromResult(Detail);
public Task<CollaborateurLicence?> AssignMobileLicenceAsync(
Guid collaborateurGuid,
CancellationToken cancellationToken = default)
{
AssignCalls++;
return ExceptionOnAssign is { } ex
? Task.FromException<CollaborateurLicence?>(ex)
: Task.FromResult<CollaborateurLicence?>(null);
}
public Task<bool> ReleaseMobileLicenceAsync(
Guid collaborateurGuid,
CancellationToken cancellationToken = default)
{
ReleaseCalls++;
return ExceptionOnRelease is { } ex
? Task.FromException<bool>(ex)
: Task.FromResult(false);
}
}
}