using System.Net.Http; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Configuration; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Organisation; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du CompteRenduCreateViewModel : vérifie que la création transmet /// l'identité du collaborateur connecté (audit CreationUser/ModifUser) et que les /// échecs réseau de cet écran — dont celui de l'anti-doublon survenu en production /// le 09/10 (« Connection failure ») — sont classés en Warning/P2, jamais en /// incident Error/P1. /// [Collection("TelemetrieStatique")] public class CompteRenduCreateViewModelTests { private sealed class FakeInterventionLocalService : IInterventionLocalService { public CreateCompteRenduRequest? LastRequest { get; private set; } public Task CreateAsync(Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UpdateAsync(Guid interventionGuid, Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ArchiveAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task CreateCompteRenduAsync( Guid rendezVousGuid, CreateCompteRenduRequest request, CancellationToken cancellationToken = default) { LastRequest = request; return Task.FromResult(new CompteRenduDto { Id = Guid.NewGuid().ToString() }); } } private sealed class FakeInterventionContactsService : IInterventionContactsService { public Task> GetContactsForInterventionAsync( Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task GetContactByIdAsync( Guid contactGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task> GetContactsDisponiblesAsync( Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeParametreMetierService : IParametreMetierLocalService { /// Référentiel « type de signataire » exposé par le bootstrap local. public IReadOnlyList TypesSignataire { get; set; } = []; public Task> GetTypeTempsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetInterventionEtatsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetSiteTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetTypeSignatairesAsync(CancellationToken ct = default) => Task.FromResult(TypesSignataire); } private sealed class FakeCompteRenduScopeService : ICompteRenduScopeService { /// Exception levée par la lecture du compte rendu (chargement de l'écran). public Exception? ExceptionOnGet { get; set; } /// Exception levée par la vérification d'existence (anti-doublon du bouton Créer). public Exception? ExceptionOnExists { get; set; } public CompteRenduScope ResolveScope(AgendaItem rendezVous) => new(rendezVous.RendezVousGuid, DateOnly.FromDateTime(rendezVous.Debut), rendezVous.InterventionGuid); public Task GetCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default) => ExceptionOnGet is { } ex ? Task.FromException(ex) : Task.FromResult(null); public Task CompteRenduExistsAsync(AgendaItem rendezVous, CancellationToken ct = default) => ExceptionOnExists is { } ex ? Task.FromException(ex) : Task.FromResult(false); public Task CompteRenduIsValidatedAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(false); public Task CanCreateCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(true); } [Fact] public async Task Save_TransmetLeCollaborateurConnectePourAudit() { // Arrange : un collaborateur connecté (bootstrap) et un rendez-vous passé. Guid collaborateurGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var session = new StubSessionService { Bootstrap = new LocalBootstrapState { Collaborateur = new CollaborateurDetailsDto { Id = collaborateurGuid.ToString("D"), NomAffiche = "Technicien" } } }; var intervention = new FakeInterventionLocalService(); var vm = new CompteRenduCreateViewModel( intervention, new FakeInterventionContactsService(), session, new FakeParametreMetierService(), new NavigationService(), new FakeCompteRenduScopeService()); DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV", Debut = passe.AddHours(10), Fin = passe.AddHours(12), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd") }; // Act : chargement puis enregistrement (création du compte rendu). await vm.LoadCommand.ExecuteAsync(rdv); await vm.SaveCommand.ExecuteAsync(null); // Assert : l'identité du collaborateur est transmise (jamais null/0). Assert.NotNull(intervention.LastRequest); Assert.Equal(collaborateurGuid.ToString("D"), intervention.LastRequest!.CollaborateurId); } /// /// Le référentiel « type de signataire » est proposé dans la liste déroulante /// du compte rendu (source unique de CR_CleTypeSignataire). /// [Fact] public async Task Load_ExposeLeReferentielDesTypesDeSignataire() { FakeParametreMetierService parametres = TypesSignataireParDefaut(); var vm = CreateViewModel(parametres); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); Assert.Equal(2, vm.TypesSignataire.Count); Assert.False(vm.AucunTypeSignataire); } /// /// Quand le rôle de la personne correspond au référentiel, le type est /// pré-sélectionné ET transmis à la création (colonne renseignée). /// [Fact] public async Task Save_TypeDeSignataireDeduit_EstTransmis() { var intervention = new FakeInterventionLocalService(); var vm = CreateViewModel(TypesSignataireParDefaut(), intervention); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); // Le client est assimilé au « Propriétaire » du référentiel local. Assert.NotNull(vm.SelectedTypeSignataire); Assert.Equal("T.SIGN.PROP", vm.SelectedTypeSignataire!.Key); await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(intervention.LastRequest); Assert.Equal("T.SIGN.PROP", intervention.LastRequest!.TypeSignataire?.Key); } /// /// Cas du bug constaté : aucun rôle ne correspond au référentiel (le type /// n'est pas déductible). L'utilisateur choisit alors explicitement le type /// dans la liste déroulante, et cette valeur est transmise. /// [Fact] public async Task Save_TypeDeSignataireNonDeduit_ObligeLeChoixPuisLeTransmet() { var intervention = new FakeInterventionLocalService(); var parametres = new FakeParametreMetierService { // Aucun libellé ne correspond au rôle « Client » : rien n'est déduit. TypesSignataire = [ new ReferenceValueDto { Key = "T.SIGN.MAND", Label = "Mandataire" }, new ReferenceValueDto { Key = "T.SIGN.OCC", Label = "Occupant" } ] }; var vm = CreateViewModel(parametres, intervention); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); Assert.Null(vm.SelectedTypeSignataire); Assert.True(vm.TypeSignataireManquant); // Sans choix, la création est bloquée avec un message explicite : le // compte rendu ne doit pas partir avec un type de signataire vide. await vm.SaveCommand.ExecuteAsync(null); Assert.Null(intervention.LastRequest); Assert.Equal("Veuillez sélectionner le type de signataire.", vm.ErrorMessage); // Choix explicite dans la liste déroulante. vm.SelectedTypeSignataire = vm.TypesSignataire[1]; await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(intervention.LastRequest); Assert.Equal("T.SIGN.OCC", intervention.LastRequest!.TypeSignataire?.Key); } /// /// Référentiel vide (paramétrage absent du bootstrap) : la création n'est pas /// bloquée — aucune valeur n'est inventée, mais l'utilisateur est informé. /// [Fact] public async Task Save_ReferentielVide_NeBloquePasLaCreation() { var intervention = new FakeInterventionLocalService(); var vm = CreateViewModel(new FakeParametreMetierService(), intervention); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); Assert.True(vm.AucunTypeSignataire); await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(intervention.LastRequest); Assert.Null(intervention.LastRequest!.TypeSignataire); } private static FakeParametreMetierService TypesSignataireParDefaut() => new() { TypesSignataire = [ new ReferenceValueDto { Key = "T.SIGN.PROP", Label = "Propriétaire" }, new ReferenceValueDto { Key = "T.SIGN.LOC", Label = "Locataire" } ] }; private static AgendaItem CreateRendezVous() { DateTime passe = DateTime.Now.AddDays(-1); return new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV", Debut = passe.AddHours(10), Fin = passe.AddHours(12), // Un compte rendu documente TOUJOURS une intervention : les rendez-vous // de ces tests en portent une, comme en production. InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), ClientNom = "Client Test" }; } private static CompteRenduCreateViewModel CreateViewModel( FakeParametreMetierService parametres, FakeInterventionLocalService? intervention = null, FakeCompteRenduScopeService? scope = null) { var session = new StubSessionService { Bootstrap = new LocalBootstrapState { Collaborateur = new CollaborateurDetailsDto { Id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", NomAffiche = "Technicien" } } }; return new CompteRenduCreateViewModel( intervention ?? new FakeInterventionLocalService(), new FakeInterventionContactsService(), session, parametres, new NavigationService(), scope ?? new FakeCompteRenduScopeService()); } /// /// Règle métier : le compte rendu ne se saisit qu'APRÈS le rendez-vous. Un /// rendez-vous futur n'ouvre aucune saisie (aucun champ affiché, aucune /// création possible) : la page explique la règle et propose le retour. /// [Fact] public async Task Load_RendezVousFutur_AucuneSaisiePossible() { // Arrange : rendez-vous planifié demain. DateTime demain = DateTime.Now.AddDays(1); var rendezVous = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV futur", Debut = demain, Fin = demain.AddHours(1), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), ClientNom = "Client Test" }; var intervention = new FakeInterventionLocalService(); CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), intervention: intervention); // Act await vm.LoadCommand.ExecuteAsync(rendezVous); // Assert : formulaire masqué, explication datée. Assert.False(vm.SaisieAutorisee); Assert.True(vm.SaisieIndisponible); Assert.False(vm.FormulaireVisible); Assert.Contains("Rendez-vous prévu le", vm.MessageSaisieIndisponible); Assert.Null(vm.ErrorMessage); // Act : enregistrement demandé malgré tout (appel indirect de la commande). await vm.SaveCommand.ExecuteAsync(null); // Assert : aucune création n'est transmise au serveur. Assert.Null(intervention.LastRequest); Assert.False(vm.IsBusy); } /// /// Règle métier : un compte rendu documente une intervention (travaux /// réalisés, réserves, signature du client). Un rendez-vous lié à AUCUNE /// intervention ne peut donc pas en porter un : la page n'ouvre aucune saisie, /// explique la règle et ne transmet jamais de création. /// [Fact] public async Task Load_SansIntervention_SaisieFermee() { // Arrange : rendez-vous passé, sans intervention liée. DateTime passe = DateTime.Now.AddDays(-1); var rendezVous = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV sans intervention", Debut = passe.AddHours(10), Fin = passe.AddHours(12), ClientNom = "Client Test" }; var intervention = new FakeInterventionLocalService(); CompteRenduCreateViewModel vm = CreateViewModel( TypesSignataireParDefaut(), intervention: intervention); // Act await vm.LoadCommand.ExecuteAsync(rendezVous); // Assert : formulaire masqué, motif dédié (ce n'est pas la règle « après le // rendez-vous » : le rendez-vous est passé). Assert.False(vm.SaisieAutorisee); Assert.True(vm.SaisieIndisponible); Assert.False(vm.FormulaireVisible); Assert.Contains("intervention", vm.ExplicationSaisieIndisponible); Assert.Null(vm.MessageSaisieIndisponible); // Act : enregistrement demandé malgré tout (appel indirect de la commande). await vm.SaveCommand.ExecuteAsync(null); // Assert : aucune création n'est transmise au serveur. Assert.Null(intervention.LastRequest); Assert.False(vm.IsBusy); } /// /// Contre-épreuve de la règle : un rendez-vous passé ouvre la saisie /// normalement (aucune explication, formulaire affiché). /// [Fact] public async Task Load_RendezVousPasse_SaisieOuverte() { CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut()); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); Assert.True(vm.SaisieAutorisee); Assert.True(vm.FormulaireVisible); Assert.False(vm.SaisieIndisponible); Assert.Null(vm.MessageSaisieIndisponible); } /// /// Échec réseau au chargement de l'écran : l'utilisateur reçoit le libellé français /// « hors ligne », et l'événement part en Warning/P2 — aucune entrée en Error/P1, /// donc aucun alimentation des groupes d'erreurs du Diagnostic Center. /// [Fact] public async Task Load_EchecReseau_EstSignaleEnWarningSansIncident() { var scope = new FakeCompteRenduScopeService { ExceptionOnGet = new HttpRequestException("Connection failure") }; CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage); AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementCompteRendu(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); } /// /// Régression du ticket de production du 09/10 : l'échec réseau de la vérification /// d'existence (anti-doublon du bouton Créer) remontait en Error/P1. Il doit /// désormais être classé en Warning/P2, à sa vraie gravité. /// [Fact] public async Task Save_EchecReseauDeLAntiDoublon_NEstPasUnIncident() { var scope = new FakeCompteRenduScopeService { ExceptionOnExists = new HttpRequestException("Connection failure") }; CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SaveCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementCompteRendu(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage); } /// /// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1 avec /// son message — le classement préalable ne dilue pas les vraies erreurs. /// [Fact] public async Task Save_ErreurMetier_ResteUnIncident() { var scope = new FakeCompteRenduScopeService { ExceptionOnExists = new InvalidOperationException("boom") }; CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope); await vm.LoadCommand.ExecuteAsync(CreateRendezVous()); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SaveCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("boom", vm.ErrorMessage); DiagnosticEvent evt = EvenementCompteRendu(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Seul événement de diagnostic attribué à ce 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 EvenementCompteRendu(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(CompteRenduCreateViewModel)); /// 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(CompteRenduCreateViewModel) && 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); } }