626 lines
24 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|