using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.Services; using Omika.App.ViewModels; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Sync; using Xunit; namespace Omika.Tests.Mobile.Local; /// /// Messagerie EXTERNE dans la création d'un courriel : « Envoyer » ouvre la /// messagerie du téléphone avec le courriel pré-rempli (aucun envoi par Omika), /// puis la réponse de l'utilisateur détermine l'état enregistré : /// /// « Oui » → courriel ENVOYÉ (ME_EnvoyerMob côté serveur) ; /// « Non » (ou retour sans confirmation) → BROUILLON. /// /// La messagerie Back-Office (interne) n'est pas affectée : l'envoi SMTP par le /// serveur reste le seul chemin. /// public class NouveauEmailExterneTests { // ===================================================================== // Construction // ===================================================================== private static NouveauEmailViewModel Creer( FakeInterventionService intervention, FakeEmailLauncher launcher, IAppRetourService? retour, FakePermissionService permissions, FakeDocumentService? documents = null) { var viewModel = new NouveauEmailViewModel( intervention, new FakeContactsService(), documents ?? new FakeDocumentService(), new NavigationService(), new FakeSyncEngine(), Omika.Core.Observability.NullTelemetryService.Instance, launcher, retour, permissions); viewModel.InterventionGuid = Guid.NewGuid(); viewModel.Sujet = "Intervention du 12"; viewModel.Corps = "

Bonjour,

Compte rendu ci-joint.

"; viewModel.Destinataires = "client@exemple.fr"; viewModel.Cc = "copie@exemple.fr"; return viewModel; } private static (NouveauEmailViewModel Vm, FakeInterventionService Intervention, FakeEmailLauncher Launcher) CreerExterne( IAppRetourService? retour = null, FakeDocumentService? documents = null) { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher(); NouveauEmailViewModel viewModel = Creer( intervention, launcher, retour ?? new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = true }, documents); return (viewModel, intervention, launcher); } // ===================================================================== // Messagerie interne : rien ne change // ===================================================================== /// Back-Office : l'envoi passe par le SERVEUR (SMTP), aucune messagerie externe. [Fact] public async Task Envoyer_BackOffice_EnvoieParLeServeurSansMessagerieExterne() { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher(); NouveauEmailViewModel vm = Creer( intervention, launcher, new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = false }); vm.ConfigurationSmtpSelectionnee = new SmtpConfigurationDto { Id = "cfg-1", Email = "moi@exemple.fr" }; await vm.EnvoyerCommand.ExecuteAsync(null); Assert.Equal(1, intervention.EnvoisInternes); Assert.Equal(0, launcher.Appels); Assert.False(vm.AfficherQuestionEnvoiExterne); } /// Back-Office : la configuration SMTP reste proposée (comportement inchangé). [Fact] public void BackOffice_AfficheLaConfigurationSmtp() { var intervention = new FakeInterventionService(); NouveauEmailViewModel vm = Creer( intervention, new FakeEmailLauncher(), new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = false }); Assert.True(vm.AfficherConfigurationSmtp); Assert.False(vm.MessagerieExterne); } // ===================================================================== // Messagerie externe : ouverture + question // ===================================================================== /// /// Externe : « Envoyer » ouvre la messagerie avec le courriel pré-rempli, /// attend le retour de l'utilisateur puis pose la question. Rien n'est encore /// enregistré à ce stade. /// [Fact] public async Task Envoyer_Externe_OuvreLaMessageriePuisDemandeConfirmation() { (NouveauEmailViewModel vm, FakeInterventionService intervention, FakeEmailLauncher launcher) = CreerExterne(); await vm.EnvoyerCommand.ExecuteAsync(null); Assert.Equal(1, launcher.Appels); Assert.NotNull(launcher.DerniereDemande); Assert.Equal(new[] { "client@exemple.fr" }, launcher.DerniereDemande!.Destinataires); Assert.Equal(new[] { "copie@exemple.fr" }, launcher.DerniereDemande.Cc); Assert.Equal("Intervention du 12", launcher.DerniereDemande.Sujet); // Le client email ne rend pas le HTML : le corps transmis est du TEXTE. Assert.DoesNotContain('<', launcher.DerniereDemande.CorpsTexte); Assert.Contains("Compte rendu ci-joint.", launcher.DerniereDemande.CorpsTexte); Assert.True(vm.AfficherQuestionEnvoiExterne); Assert.False(vm.AfficherConfigurationSmtp); Assert.Equal(0, intervention.EnvoisInternes); Assert.Equal(0, intervention.EnregistrementsEnvoyes); Assert.Equal(0, intervention.EnregistrementsBrouillon); } /// « Oui » : le courriel est enregistré ENVOYÉ (ME_EnvoyerMob = vrai côté serveur). [Fact] public async Task RepondreOui_EnregistreLeCourrielEnvoye() { (NouveauEmailViewModel vm, FakeInterventionService intervention, _) = CreerExterne(); await vm.EnvoyerCommand.ExecuteAsync(null); await vm.RepondreEnvoiExterneOuiCommand.ExecuteAsync(null); Assert.Equal(1, intervention.EnregistrementsEnvoyes); Assert.Equal(0, intervention.EnregistrementsBrouillon); Assert.Equal(0, intervention.EnvoisInternes); Assert.False(vm.AfficherQuestionEnvoiExterne); CreateEmailInterventionRequest request = Assert.Single(intervention.DemandesEnvoyees); Assert.Equal("Intervention du 12", request.Objet); // Le corps enregistré reste le HTML d'origine (seul l'aperçu est aplati). Assert.Equal(vm.Corps, request.Message); } /// « Non » : le courriel est enregistré en BROUILLON, jamais comme envoyé. [Fact] public async Task RepondreNon_EnregistreUnBrouillon() { (NouveauEmailViewModel vm, FakeInterventionService intervention, _) = CreerExterne(); await vm.EnvoyerCommand.ExecuteAsync(null); await vm.RepondreEnvoiExterneNonCommand.ExecuteAsync(null); Assert.Equal(0, intervention.EnregistrementsEnvoyes); Assert.Equal(1, intervention.EnregistrementsBrouillon); Assert.False(vm.AfficherQuestionEnvoiExterne); } /// /// Aucune application de messagerie : un message clair, et surtout RIEN n'est /// enregistré (ni envoyé, ni brouillon). /// [Fact] public async Task Envoyer_Externe_SansMessagerieDisponible_RienNEstEnregistre() { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher { Resultat = EmailExterneResultat.Echec }; NouveauEmailViewModel vm = Creer( intervention, launcher, new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = true }); await vm.EnvoyerCommand.ExecuteAsync(null); Assert.NotNull(vm.ErrorMessage); Assert.False(vm.AfficherQuestionEnvoiExterne); Assert.Equal(0, intervention.EnregistrementsEnvoyes); Assert.Equal(0, intervention.EnregistrementsBrouillon); } /// /// Composeur système qui CONNAÎT le résultat (iOS avec pièces jointes) : /// envoyé → enregistrement direct, sans question ; annulé → brouillon. /// [Theory] [InlineData(true)] [InlineData(false)] public async Task ComposeurSysteme_ResultatConnu_EnregistreSansQuestion(bool envoye) { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher { Resultat = EmailExterneResultat.Confirme(envoye) }; NouveauEmailViewModel vm = Creer( intervention, launcher, new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = true }); await vm.EnvoyerCommand.ExecuteAsync(null); Assert.False(vm.AfficherQuestionEnvoiExterne); Assert.Equal(envoye ? 1 : 0, intervention.EnregistrementsEnvoyes); Assert.Equal(envoye ? 0 : 1, intervention.EnregistrementsBrouillon); } /// /// Pièces jointes cochées : leurs chemins LOCAUX (téléchargés au besoin) sont /// transmis à la messagerie ; un document introuvable est simplement omis. /// [Fact] public async Task Envoyer_Externe_TransmetLesCheminsDesPiecesJointes() { string chemin = Path.Combine(Path.GetTempPath(), $"omika-pj-{Guid.NewGuid():N}.pdf"); await File.WriteAllTextAsync(chemin, "pdf"); try { var documents = new FakeDocumentService { CheminLocal = chemin }; (NouveauEmailViewModel vm, _, FakeEmailLauncher launcher) = CreerExterne(documents: documents); Guid documentGuid = Guid.NewGuid(); vm.PiecesJointes.Add(new PieceJointeOption(documentGuid.ToString(), "Rapport", estPhoto: false) { EstSelectionne = true }); await vm.EnvoyerCommand.ExecuteAsync(null); Assert.NotNull(launcher.DerniereDemande); Assert.Equal(new[] { chemin }, launcher.DerniereDemande!.CheminsPiecesJointes); } finally { File.Delete(chemin); } } /// /// Sans moyen d'observer le retour (plateforme de test), la question est posée /// immédiatement : aucun enregistrement silencieux dans un état incertain. /// [Fact] public async Task Envoyer_Externe_SansObservationDuRetour_PoseLaQuestion() { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher(); NouveauEmailViewModel vm = Creer( intervention, launcher, retour: null, new FakePermissionService { MessagerieExterneValue = true }); await vm.EnvoyerCommand.ExecuteAsync(null); Assert.True(vm.AfficherQuestionEnvoiExterne); Assert.Equal(0, intervention.EnregistrementsEnvoyes); Assert.Equal(0, intervention.EnregistrementsBrouillon); } /// Édition d'un brouillon existant : « Oui » met CE brouillon à jour en envoyé. [Fact] public async Task Edition_Externe_RepondreOui_MarqueLeBrouillonEnvoye() { var intervention = new FakeInterventionService(); var launcher = new FakeEmailLauncher(); NouveauEmailViewModel vm = Creer( intervention, launcher, new FakeAppRetourService(), new FakePermissionService { MessagerieExterneValue = true }); var brouillon = new EmailInterventionDto { Id = "email-42", Objet = "Objet", Message = "Corps", Brouillon = true }; await vm.LoadEditionCommand.ExecuteAsync( new EmailEditionNavigation(vm.InterventionGuid, brouillon)); vm.Sujet = "Objet modifié"; vm.Corps = "Corps modifié"; vm.Destinataires = "client@exemple.fr"; await vm.EnvoyerCommand.ExecuteAsync(null); await vm.RepondreEnvoiExterneOuiCommand.ExecuteAsync(null); Assert.Equal(1, intervention.EnregistrementsEnvoyes); Assert.Equal(0, intervention.EnregistrementsBrouillon); Assert.Equal("email-42", intervention.DernierEmailId); } // ===================================================================== // Fakes // ===================================================================== private sealed class FakeEmailLauncher : IEmailLauncher { public int Appels { get; private set; } public EmailExterneDemande? DerniereDemande { get; private set; } public EmailExterneResultat Resultat { get; set; } = EmailExterneResultat.LanceSansConfirmation; public Task ComposeAsync(string email) => Task.FromResult(true); public Task ComposerEmailExterneAsync(EmailExterneDemande demande) { Appels++; DerniereDemande = demande; return Task.FromResult(Resultat); } } /// Retour au premier plan simulé : le retour est immédiat. private sealed class FakeAppRetourService : IAppRetourService { public IAppRetourAttente Armer() => new RetourImmediat(); private sealed class RetourImmediat : IAppRetourAttente { public Task AttendreAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void Dispose() { } } } private sealed class FakePermissionService : IPermissionService { public bool MessagerieExterneValue { get; init; } public IReadOnlyDictionary Rights => new Dictionary(); public void SetRights(IReadOnlyDictionary rights) { } public bool HasRight(string key) => true; public bool MessagerieExterne => MessagerieExterneValue; public void SetMessagerieExterne(bool externe) { } public bool PeutRechercherInterventions => true; public bool VoirRdvSansRestriction => true; public bool PeutVoirAgendaEquipe => true; public bool PeutEnvoyerEmail => !MessagerieExterneValue; public bool PeutComposerEmail => true; public bool PeutCreerRapport => true; public bool PeutGererLicence => true; public bool PeutSaisirTempsAutrui => true; public bool PeutVoirNotesAdmin => true; public bool PeutVoirClients => true; public bool PeutVoirSites => true; public bool PeutVoirDossiers => true; } private sealed class FakeInterventionService : IInterventionLocalService { public int EnvoisInternes { get; private set; } public int EnregistrementsEnvoyes { get; private set; } public int EnregistrementsBrouillon { get; private set; } /// Identifiant du dernier courriel touché (édition). public string? DernierEmailId { get; private set; } public List DemandesEnvoyees { get; } = []; public Task SendEmailAsync( Guid interventionGuid, string? emailId, CreateEmailInterventionRequest request, CancellationToken cancellationToken = default) { EnvoisInternes++; return Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString() }); } public Task AddEmailAsync( Guid interventionGuid, CreateEmailInterventionRequest request, CancellationToken cancellationToken = default) { EnregistrementsBrouillon++; return Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString(), Brouillon = true }); } public Task UpdateEmailAsync( Guid interventionGuid, string emailId, CreateEmailInterventionRequest request, CancellationToken cancellationToken = default) { EnregistrementsBrouillon++; return Task.FromResult(new EmailInterventionDto { Id = emailId, Brouillon = true }); } public Task AddEmailEnvoyeExterneAsync( Guid interventionGuid, CreateEmailInterventionRequest request, CancellationToken cancellationToken = default) { EnregistrementsEnvoyes++; DemandesEnvoyees.Add(request); return Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString() }); } public Task UpdateEmailEnvoyeExterneAsync( Guid interventionGuid, string emailId, CreateEmailInterventionRequest request, CancellationToken cancellationToken = default) { EnregistrementsEnvoyes++; DernierEmailId = emailId; DemandesEnvoyees.Add(request); return Task.FromResult(new EmailInterventionDto { Id = emailId }); } 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 FakeContactsService : IInterventionContactsService { public Task> GetContactsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task GetContactByIdAsync( Guid contactGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetContactsDisponiblesAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeDocumentService : IDocumentLocalService { public string? CheminLocal { get; init; } 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(CheminLocal); 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()); } }