using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Sync; using Xunit; namespace Omika.Tests.Mobile.Local; /// /// Liste des destinataires proposée à la composition d'un courriel : elle contient /// UNIQUEMENT les contacts de l'intervention, puis les contacts du carnet /// d'adresses du téléphone. Chaque entrée porte son ORIGINE (« Intervention » / /// « Téléphone ») pour qu'un contact personnel ne soit jamais confondu avec un /// contact de l'intervention, même à nom identique. /// public class NouveauEmailContactsDisponiblesTests { // ===================================================================== // Helpers // ===================================================================== private static NouveauEmailViewModel Creer( IInterventionContactsService contacts, IContactsTelephoneService? contactsTelephone) { var viewModel = new NouveauEmailViewModel( new FakeInterventionService(), contacts, new FakeDocumentService(), new NavigationService(), new FakeSyncEngine(), Omika.Core.Observability.NullTelemetryService.Instance, emailLauncher: null, appRetour: null, permissions: null, contactsTelephone: contactsTelephone); viewModel.InterventionGuid = Guid.NewGuid(); // « Email vierge » : aucun appel au serveur pour l'aperçu du modèle, mais la // liste des destinataires est bien construite (comme en composition réelle). viewModel.ModeleSelectionne = new ModeleMailDto { Id = string.Empty, Nom = "Email vierge" }; return viewModel; } private static InterventionContact ContactIntervention(string nom, string? email) => new() { ContactGuid = Guid.NewGuid(), DisplayName = nom, Email = email }; // ===================================================================== // Sources et origines // ===================================================================== /// /// Les contacts de l'intervention viennent en premier, puis ceux du téléphone, /// chacun identifié par son origine. /// [Fact] public async Task Liste_InterventionPuisTelephone_AvecOrigineIdentifiee() { var contacts = new FakeContactsService { Disponibles = [ ContactIntervention("Alice Dupont", "alice@test.fr"), ContactIntervention("Bruno Petit", "bruno@test.fr") ] }; var telephone = new FakeContactsTelephoneService { Contacts = [ new ContactTelephone("Zoé Martin", "zoe@perso.fr"), new ContactTelephone("Benoit Roux", "benoit@perso.fr") ] }; NouveauEmailViewModel vm = Creer(contacts, telephone); await vm.ContinuerCommand.ExecuteAsync(null); // Assert : 4 propositions, intervention d'abord, puis téléphone trié par nom. Assert.Equal(4, vm.ContactsDisponibles.Count); Assert.Equal( new[] { "Alice Dupont — alice@test.fr", "Bruno Petit — bruno@test.fr", "Benoit Roux — benoit@perso.fr", "Zoé Martin — zoe@perso.fr" }, vm.ContactsDisponibles.Select(c => c.Libelle)); // L'origine est explicite pour chaque entrée. Assert.Equal( new[] { OrigineContactEmail.Intervention, OrigineContactEmail.Intervention, OrigineContactEmail.Telephone, OrigineContactEmail.Telephone }, vm.ContactsDisponibles.Select(c => c.Origine)); Assert.Equal( new[] { "Intervention", "Intervention", "Téléphone", "Téléphone" }, vm.ContactsDisponibles.Select(c => c.OrigineLibelle)); Assert.Equal(new[] { true, true, false, false }, vm.ContactsDisponibles.Select(c => c.EstContactIntervention)); } /// /// Une adresse déjà proposée par l'intervention n'est pas dupliquée depuis le /// carnet du téléphone : l'origine « Intervention » est conservée. /// [Fact] public async Task Telephone_MemeAdresse_FusionneeEnFaveurDeLIntervention() { var contacts = new FakeContactsService { Disponibles = [ContactIntervention("Alice Dupont", "alice@test.fr")] }; var telephone = new FakeContactsTelephoneService { Contacts = [new ContactTelephone("Alice D.", "ALICE@test.fr")] }; NouveauEmailViewModel vm = Creer(contacts, telephone); await vm.ContinuerCommand.ExecuteAsync(null); EmailContactOption option = Assert.Single(vm.ContactsDisponibles); Assert.Equal("alice@test.fr", option.Email); Assert.Equal(OrigineContactEmail.Intervention, option.Origine); } /// /// Une entrée sans adresse email valide n'est jamais proposée, quelle que soit /// sa source. /// [Fact] public async Task ContactSansAdresseValide_NEstPasPropose() { var contacts = new FakeContactsService { Disponibles = [ ContactIntervention("Alice Dupont", "alice@test.fr"), ContactIntervention("Sans adresse", null), ContactIntervention("Adresse vide", " ") ] }; var telephone = new FakeContactsTelephoneService { Contacts = [ new ContactTelephone("Carnet invalide", "pas-une-adresse"), new ContactTelephone("Carnet vide", ""), new ContactTelephone("Carnet valide", "carnet@perso.fr") ] }; NouveauEmailViewModel vm = Creer(contacts, telephone); await vm.ContinuerCommand.ExecuteAsync(null); Assert.Equal( new[] { "Alice Dupont — alice@test.fr", "Carnet valide — carnet@perso.fr" }, vm.ContactsDisponibles.Select(c => c.Libelle)); } // ===================================================================== // Sources optionnelles : la composition n'échoue jamais // ===================================================================== /// Plateforme sans carnet d'adresses : seuls les contacts de l'intervention sont proposés. [Fact] public async Task SansCarnetTelephone_ListeLimiteeALIntervention() { var contacts = new FakeContactsService { Disponibles = [ContactIntervention("Alice Dupont", "alice@test.fr")] }; NouveauEmailViewModel vm = Creer(contacts, contactsTelephone: null); await vm.ContinuerCommand.ExecuteAsync(null); EmailContactOption option = Assert.Single(vm.ContactsDisponibles); Assert.Equal(OrigineContactEmail.Intervention, option.Origine); } /// Autorisation refusée / lecture du carnet en erreur : la composition reste possible. [Fact] public async Task CarnetEnErreur_NAffectePasLaComposition() { var contacts = new FakeContactsService { Disponibles = [ContactIntervention("Alice Dupont", "alice@test.fr")] }; NouveauEmailViewModel vm = Creer(contacts, new FakeContactsTelephoneService { Echoue = true }); await vm.ContinuerCommand.ExecuteAsync(null); Assert.Single(vm.ContactsDisponibles); Assert.Null(vm.ErrorMessage); } /// Liste des contacts de l'intervention indisponible : les contacts du téléphone restent proposés. [Fact] public async Task InterventionEnErreur_GardeLesContactsDuTelephone() { var contacts = new FakeContactsService { Echoue = true }; var telephone = new FakeContactsTelephoneService { Contacts = [new ContactTelephone("Zoé Martin", "zoe@perso.fr")] }; NouveauEmailViewModel vm = Creer(contacts, telephone); await vm.ContinuerCommand.ExecuteAsync(null); EmailContactOption option = Assert.Single(vm.ContactsDisponibles); Assert.Equal(OrigineContactEmail.Telephone, option.Origine); Assert.Equal("Téléphone", option.OrigineLibelle); } // ===================================================================== // Fakes // ===================================================================== private sealed class FakeContactsService : IInterventionContactsService { public IReadOnlyList Disponibles { get; set; } = []; public bool Echoue { get; set; } public Task> GetContactsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(Disponibles); public Task GetContactByIdAsync( Guid contactGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetContactsDisponiblesAsync( Guid interventionGuid, CancellationToken ct = default) => Echoue ? throw new InvalidOperationException("Contacts de l'intervention indisponibles.") : Task.FromResult(Disponibles); } private sealed class FakeContactsTelephoneService : IContactsTelephoneService { public IReadOnlyList Contacts { get; set; } = []; public bool Echoue { get; set; } public Task> GetContactsAvecEmailAsync(CancellationToken cancellationToken = default) => Echoue ? throw new InvalidOperationException("Carnet d'adresses indisponible (autorisation refusée).") : Task.FromResult(Contacts); } private sealed class FakeInterventionService : IInterventionLocalService { public Task> GetMailChampsAsync( GetRapportChampsRequest request, CancellationToken ct = default) => Task.FromResult>([]); public Task GetEmailModeleApercuAsync( CreateEmailModeleRequest request, CancellationToken ct = default) => Task.FromResult(null); public Task> GetSmtpConfigurationsAsync( CancellationToken ct = default) => Task.FromResult>([]); public Task AddEmailAsync( Guid interventionGuid, CreateEmailInterventionRequest request, CancellationToken ct = default) => Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString() }); public Task CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task ArchiveAsync(Guid interventionGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeDocumentService : IDocumentLocalService { public Task> GetDocumentsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task OpenAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default) => Task.FromResult(new DocumentNoteItem()); public Task AddPhotoAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddPhotosAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(new AddPhotosResult()); public Task AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddDocumentAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddAnnotatedPhotoAsync( Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeSyncEngine : ISyncEngine { public Task SyncAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); public Task PushAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); } }