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