using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Repositories; using Omika.App.Local.Services; using Omika.App.Navigation; using Omika.App.Services; using Omika.App.ViewModels; using Omika.Core.Contracts.Clients; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Planning; using Omika.Core.Contracts.Sites; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel Fiche Intervention : chargement, état introuvable, /// indicateurs de relations et navigation vers un rendez-vous lié. /// public class InterventionDetailViewModelTests : LocalTestBase { private readonly InterventionDetailViewModel _viewModel; private readonly NavigationService _navigation; private readonly FakePhoneLauncher _phoneLauncher = new(); private readonly FakeEmailLauncher _emailLauncher = new(); private readonly PermissionService _permissions = new(); private sealed class FakeContactsService : 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 FakePhoneLauncher : IPhoneLauncher { public string? LastNumber { get; private set; } public Task DialAsync(string phoneNumber) { LastNumber = phoneNumber; return Task.FromResult(true); } } private sealed class FakeEmailLauncher : IEmailLauncher { public string? LastEmail { get; private set; } public Task ComposeAsync(string email) { LastEmail = email; return Task.FromResult(true); } } private sealed class FakeDocumentService : IDocumentLocalService { public Task> GetDocumentsForInterventionAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task OpenAsync(Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task GetCachedFilePathAsync(Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync(Guid documentGuid, string contenu, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AddPhotoAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AddPhotosAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(new AddPhotosResult()); public Task AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AddDocumentAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AddAnnotatedPhotoAsync(Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class FakeMapLauncher : IMapLauncher { public Task OpenItineraireAsync(string address) => Task.FromResult(true); } public InterventionDetailViewModelTests() { IInterventionLocalService interventionService = new InterventionLocalService( InterventionRepository, ClientRepository, SiteRepository, DossierRepository, RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository, OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database); var clientService = new ClientLocalService( ClientRepository, DossierRepository, ContextInterventionCacheRepository, ClientStateRepository, ApiClient); _navigation = new NavigationService(); _viewModel = new InterventionDetailViewModel( interventionService, OutboxRepository, new FakeContactsService(), clientService, new FakeDocumentService(), _phoneLauncher, _emailLauncher, _navigation, _permissions, new RapportBrouillonRepository(Database), new FakeMapLauncher(), Network); } private static LocalClient CreateClient( Guid id, string nom, string? code = null, string? phone = null, string? mobile = null, string? email = null) { bool aDesCoordonnees = phone is not null || mobile is not null || email is not null; var dto = new ClientDetailsDto { Id = id.ToString(), Nom = nom, Code = code, Coordonnees = aDesCoordonnees ? new ContactDetailsDto { Phone = phone, Mobile = mobile, Email = email } : null }; return new LocalClient { Id = id, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalIntervention CreateIntervention(Guid id, string reference, Guid? clientId = null) { var dto = new InterventionDetailsDto { Id = id.ToString(), Reference = reference, Titre = "Intervention", ClientId = clientId?.ToString(), Etat = new ReferenceValueDto { Label = "En cours" } }; return new LocalIntervention { Id = id, ClientId = clientId, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalSite CreateSite(Guid id, string nom) { var dto = new SiteDetailsDto { Id = id.ToString(), Nom = nom }; return new LocalSite { Id = id, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalRendezVous CreateRendezVous(Guid id, Guid interventionId, string titre) { var dto = new RendezVousDetailsDto { Id = id.ToString(), Titre = titre, Debut = new DateTime(2026, 8, 12, 9, 0, 0), Fin = new DateTime(2026, 8, 12, 10, 0, 0), InterventionId = interventionId.ToString(), Statut = new ReferenceValueDto { Label = "Planifié" } }; return new LocalRendezVous { Id = id, InterventionId = interventionId, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task Load_InterventionTrouvee_PeutLaFiche() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert Assert.False(_viewModel.IsLoading); Assert.Null(_viewModel.ErrorMessage); Assert.NotNull(_viewModel.Intervention); Assert.Equal("INT-001", _viewModel.Intervention.Reference); } [Fact] public async Task Load_InterventionIntrouvable_MessageErreur() { // Act await _viewModel.LoadCommand.ExecuteAsync(Guid.NewGuid()); // Assert Assert.False(_viewModel.IsLoading); Assert.NotNull(_viewModel.ErrorMessage); Assert.Null(_viewModel.Intervention); } [Fact] public async Task Load_AvecClient_HasClientVrai() { // Arrange Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert Assert.True(_viewModel.HasClient); } [Fact] public async Task Load_SansClient_HasClientFaux() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert Assert.False(_viewModel.HasClient); Assert.False(_viewModel.HasSite); Assert.False(_viewModel.HasDossier); Assert.False(_viewModel.HasRendezVous); Assert.False(_viewModel.HasDocuments); } [Fact] public async Task Load_AvecSuiviPar_ExposeAppelEtEmailPersonnel() { // Arrange Guid interventionId = Guid.NewGuid(); var dto = new InterventionDetailsDto { Id = interventionId.ToString(), Reference = "INT-001", Titre = "Intervention", SuiviParNom = "Dupont", SuiviParMobile = "0612345678", SuiviParEmail = "dupont@example.com" }; await InterventionRepository.UpsertAsync(new LocalIntervention { Id = interventionId, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : les boutons sont visibles et déclenchent le composeur système // (appel vers le mobile + email via la messagerie personnelle). Assert.True(_viewModel.HasSuiviParPhone); Assert.True(_viewModel.HasSuiviParEmail); await _viewModel.CallSuiviParCommand.ExecuteAsync(null); Assert.Equal("0612345678", _phoneLauncher.LastNumber); await _viewModel.EmailSuiviParCommand.ExecuteAsync(null); Assert.Equal("dupont@example.com", _emailLauncher.LastEmail); } [Fact] public async Task Load_AvecRendezVous_HasRendezVousVrai() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), interventionId, "RDV")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert Assert.True(_viewModel.HasRendezVous); Assert.Single(_viewModel.Intervention!.RendezVous); } [Fact] public async Task OpenRendezVous_NavigueVersDetail() { // Arrange Guid interventionId = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await RendezVousRepository.UpsertAsync(CreateRendezVous(rdvId, interventionId, "RDV")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act _viewModel.OpenRendezVousCommand.Execute(_viewModel.Intervention!.RendezVous[0]); // Assert : navigation vers le détail du rendez-vous avec le GUID public et le retour vers la fiche Intervention. Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage); var nav = Assert.IsType(_navigation.CurrentParameter); Assert.Equal(rdvId, nav.Id); Assert.Equal(AppPage.InterventionDetail, nav.ReturnContext.Page); Assert.Equal(interventionId, nav.ReturnContext.Parameter); } [Fact] public async Task OpenRendezVous_Null_NeNaviguePas() { // Act _viewModel.OpenRendezVousCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task Back_RetourneALAgenda() { // Act _viewModel.BackCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task OpenDocuments_NavigueVersDocuments() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act _viewModel.OpenDocumentsCommand.Execute(null); // Assert : navigation vers la page « Contenus associés », onglet Documents. Assert.Equal(AppPage.InterventionResources, _navigation.CurrentPage); var nav = Assert.IsType(_navigation.CurrentParameter); Assert.Equal(interventionId, nav.InterventionGuid); Assert.Equal(1, nav.TabIndex); } [Fact] public async Task OpenContacts_NavigueVersContacts() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act _viewModel.OpenContactsCommand.Execute(null); // Assert : navigation vers la page Contacts de l'intervention avec le GUID public. Assert.Equal(AppPage.InterventionContacts, _navigation.CurrentPage); Assert.Equal(interventionId, _navigation.CurrentParameter); } [Fact] public async Task OpenContacts_InterventionNull_NeNaviguePas() { // Act _viewModel.OpenContactsCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task Load_EnLigne_CartesCourrielsEtRapportsAccessibles() { // Arrange Network.Online = true; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : en ligne, les deux cartes restent utilisables. Assert.False(_viewModel.IsOffline); Assert.True(_viewModel.PeutOuvrirCourriels); Assert.True(_viewModel.PeutOuvrirRapports); } [Fact] public async Task Load_HorsLigne_DesactiveLesCartesCourrielsEtRapports() { // Arrange Network.Online = false; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : hors ligne les cartes restent VISIBLES (aucun masquage) mais // sont désactivées : courriels et rapports passent par le serveur. Assert.True(_viewModel.IsOffline); Assert.False(_viewModel.PeutOuvrirCourriels); Assert.False(_viewModel.PeutOuvrirRapports); } /// /// Messagerie EXTERNE : l'action « écrire un courriel » à un contact ouvre /// DIRECTEMENT l'application de messagerie du téléphone, sans passer par le /// composeur interne (le serveur ne lui expose d'ailleurs aucune configuration /// SMTP). /// [Fact] public async Task EmailContact_MessagerieExterne_OuvreLApplicationDeMessagerie() { // Arrange : droit d'envoi présent, mais messagerie externe. _permissions.SetRights(new Dictionary { [Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true }); _permissions.SetMessagerieExterne(true); Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); var contact = new InterventionContact { Email = "contact@exemple.test", DisplayName = "Contact" }; // Act await _viewModel.EmailContactCommand.ExecuteAsync(contact); // Assert : composeur externe utilisé, aucune navigation vers l'écran interne. Assert.Equal("contact@exemple.test", _emailLauncher.LastEmail); Assert.NotEqual(AppPage.NouveauEmail, _navigation.CurrentPage); Assert.False(_viewModel.PeutEnvoyerEmail); Assert.True(_viewModel.PeutEcrireCourriel); } [Fact] public async Task OpenEmails_EnLigne_NavigueVersLesCourriels() { // Arrange Network.Online = true; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.OpenEmailsCommand.ExecuteAsync(null); // Assert Assert.Equal(AppPage.InterventionEmails, _navigation.CurrentPage); Assert.Equal(interventionId, _navigation.CurrentParameter); } [Fact] public async Task OpenEmails_CoupureApresChargement_NeNaviguePas() { // Arrange : la fiche est chargée en ligne puis le réseau est perdu. Network.Online = true; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); Network.Online = false; // Act await _viewModel.OpenEmailsCommand.ExecuteAsync(null); // Assert : aucun écran inutilisable ouvert, l'état hors ligne est affiché // (bandeau + cartes désactivées). Assert.Equal(AppPage.Home, _navigation.CurrentPage); Assert.True(_viewModel.IsOffline); Assert.False(_viewModel.PeutOuvrirCourriels); } [Fact] public async Task OpenRapport_EnLigne_NavigueVersLesRapports() { // Arrange Network.Online = true; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.OpenRapportCommand.ExecuteAsync(null); // Assert Assert.Equal(AppPage.RapportChamps, _navigation.CurrentPage); Assert.Equal(interventionId, _navigation.CurrentParameter); } [Fact] public async Task OpenRapport_CoupureApresChargement_NeNaviguePas() { // Arrange Network.Online = true; Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); Network.Online = false; // Act await _viewModel.OpenRapportCommand.ExecuteAsync(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); Assert.True(_viewModel.IsOffline); Assert.False(_viewModel.PeutOuvrirRapports); } [Fact] public async Task Load_AvecClient_AfficheLeBlocClient() { // Arrange : client lié présent dans le snapshot local. Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync(CreateClient(clientId, "Boulangerie Martin", "CLI-042", "0240000000")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : le bloc Client est affiché et porte les informations de base. Assert.True(_viewModel.HasClient); Assert.NotNull(_viewModel.Client); Assert.Equal("Boulangerie Martin", _viewModel.ClientNomAffiche); Assert.Equal("CLI-042", _viewModel.Client.Code); Assert.True(_viewModel.Client.HasPhone); } [Fact] public async Task Load_SansClient_BlocClientMasque() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : aucun bloc Client (ni bouton) sans client lié. Assert.False(_viewModel.HasClient); Assert.Null(_viewModel.Client); Assert.Null(_viewModel.ClientNomAffiche); Assert.False(_viewModel.PeutOuvrirClient); } [Fact] public async Task OpenClient_NavigueVersLaFicheClient() { // Arrange Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync(CreateClient(clientId, "Boulangerie Martin")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act _viewModel.OpenClientCommand.Execute(null); // Assert : navigation vers la fiche du client lié (GUID public). Assert.Equal(AppPage.ClientDetail, _navigation.CurrentPage); Assert.Equal(clientId, _navigation.CurrentParameter); } [Fact] public async Task OpenClient_SansClient_NeNaviguePas() { // Arrange Guid interventionId = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001")); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act _viewModel.OpenClientCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task Load_ClientLie_SansDroitVoirClients_BoutonFicheClientMasque() { // Arrange : client lié mais aucun droit A.CLIENT.VOIR. _permissions.SetRights(new Dictionary { [Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true }); Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync(CreateClient(clientId, "Boulangerie Martin")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); // Act await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Assert : le bloc reste visible (nom du client) mais l'ouverture est masquée, // comme pour la fiche du lieu. Assert.True(_viewModel.HasClient); Assert.False(_viewModel.PeutOuvrirClient); } /// /// Les boutons de la ligne client se comportent comme ceux d'une ligne de /// contact : appel vers le mobile (prioritaire sur le fixe), courriel via le /// composeur interne ou la messagerie du téléphone. /// [Fact] public async Task CallClient_AppelleLeMobileDuClient() { // Arrange : le mobile est prioritaire sur le fixe. Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync( CreateClient(clientId, "Boulangerie Martin", phone: "0240000000", mobile: "0612345678")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.CallClientCommand.ExecuteAsync(null); // Assert Assert.Equal("0612345678", _phoneLauncher.LastNumber); } [Fact] public async Task CallClient_SansNumero_NAppellePas() { // Arrange : client lié mais sans aucun numéro. Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync(CreateClient(clientId, "Boulangerie Martin")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.CallClientCommand.ExecuteAsync(null); // Assert : aucun appel composé et aucune erreur affichée. Assert.Null(_phoneLauncher.LastNumber); Assert.Null(_viewModel.ErrorMessage); } [Fact] public async Task EmailClient_MessagerieInterne_OuvreLeComposeurAvecLAdresseDuClient() { // Arrange : messagerie interne + droit d'envoi. _permissions.SetRights(new Dictionary { [Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true }); Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync( CreateClient(clientId, "Boulangerie Martin", email: "contact@boulangerie.test")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.EmailClientCommand.ExecuteAsync(null); // Assert : composeur interne pré-rempli avec l'adresse du client. Assert.Equal(AppPage.NouveauEmail, _navigation.CurrentPage); var nav = Assert.IsType(_navigation.CurrentParameter); Assert.Equal(interventionId, nav.InterventionGuid); Assert.Equal("contact@boulangerie.test", nav.DestinataireEmail); Assert.Null(_emailLauncher.LastEmail); } [Fact] public async Task EmailClient_MessagerieExterne_OuvreLApplicationDeMessagerie() { // Arrange : messagerie EXTERNE (application du téléphone). _permissions.SetRights(new Dictionary { [Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true }); _permissions.SetMessagerieExterne(true); Guid clientId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync( CreateClient(clientId, "Boulangerie Martin", email: "contact@boulangerie.test")); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", clientId)); await _viewModel.LoadCommand.ExecuteAsync(interventionId); // Act await _viewModel.EmailClientCommand.ExecuteAsync(null); // Assert : ouverture directe de la messagerie, sans écran interne. Assert.Equal("contact@boulangerie.test", _emailLauncher.LastEmail); Assert.NotEqual(AppPage.NouveauEmail, _navigation.CurrentPage); } }