Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user