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());
}
}