using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Clients; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Organisation; using Omika.Core.Contracts.Planning; using Omika.Core.Contracts.Sites; namespace Omika.Tests.Mobile.Local; /// /// Tests du service Agenda : lecture locale SQLite, filtrage par journée, /// enrichissement Client/Site/Intervention, tri chronologique et détail. /// public class AgendaServiceTests : LocalTestBase { private readonly AgendaService _service; public AgendaServiceTests() { _service = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, ApiClient, SessionService); } private static string Serialize(T value) => JsonSerializer.Serialize(value); private static LocalRendezVous CreateRendezVous( Guid id, DateTime debut, DateTime fin, string? clientId = null, string? siteId = null, string? interventionId = null, string? titre = "Rendez-vous", string? statut = "Planifié", string? typeActivite = "Visite", bool estAnnule = false) { var dto = new RendezVousDetailsDto { Id = id.ToString(), Titre = titre, Debut = debut, Fin = fin, ClientId = clientId, SiteId = siteId, InterventionId = interventionId, Statut = statut is null ? null : new ReferenceValueDto { Label = statut }, TypeActivite = typeActivite is null ? null : new ReferenceValueDto { Label = typeActivite }, Annulation = estAnnule ? new AnnulationRendezVousDto { Motif = new ReferenceValueDto { Label = "Annulé" } } : null }; return new LocalRendezVous { Id = id, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalClient CreateClient(Guid id, string nom) { var dto = new ClientDetailsDto { Id = id.ToString(), Nom = nom }; return new LocalClient { Id = id, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalSite CreateSite(Guid id, string nom, Guid clientId) { var dto = new SiteDetailsDto { Id = id.ToString(), Nom = nom, ClientId = clientId.ToString() }; return new LocalSite { Id = id, ClientId = clientId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalIntervention CreateIntervention( Guid id, string reference, Guid clientId, Guid siteId, string? referenceExterne = null, string? numeroDevis = null) { var dto = new InterventionDetailsDto { Id = id.ToString(), Reference = reference, ReferenceExterne = referenceExterne, NumeroDevis = numeroDevis, ClientId = clientId.ToString(), SiteId = siteId.ToString() }; return new LocalIntervention { Id = id, ClientId = clientId, SiteId = siteId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task JourSansRendezVous_RetourneListeVide() { // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(DateTime.Today); // Assert Assert.Empty(result); } [Fact] public async Task FiltreParJournee_IgnoreAutresJours() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10))); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddDays(1).AddHours(9), jour.AddDays(1).AddHours(10))); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : seul le rendez-vous du jour ciblé est retourné. Assert.Single(result); } [Fact] public async Task TriChronologique_ParHeureDebut() { // Arrange DateTime jour = new(2026, 8, 12); Guid tard = Guid.NewGuid(); Guid tot = Guid.NewGuid(); await RendezVousRepository.UpsertAsync(CreateRendezVous(tard, jour.AddHours(14), jour.AddHours(15), titre: "Tard")); await RendezVousRepository.UpsertAsync(CreateRendezVous(tot, jour.AddHours(8), jour.AddHours(9), titre: "Tôt")); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : trié par heure de début croissante. Assert.Equal(2, result.Count); Assert.Equal("Tôt", result[0].Titre); Assert.Equal("Tard", result[1].Titre); } [Fact] public async Task Enrichissement_ClientSiteIntervention() { // Arrange Guid clientId = Guid.NewGuid(); Guid siteId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); await ClientRepository.UpsertAsync(CreateClient(clientId, "Client Alpha")); await SiteRepository.UpsertAsync(CreateSite(siteId, "Site Beta", clientId)); await InterventionRepository.UpsertAsync(CreateIntervention( interventionId, "INT-001", clientId, siteId, "EXT-7", "DEV-42")); await RendezVousRepository.UpsertAsync(CreateRendezVous( rdvId, jour.AddHours(9), jour.AddHours(10), clientId.ToString(), siteId.ToString(), interventionId.ToString())); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : les références sont enrichies. AgendaItem item = Assert.Single(result); Assert.Equal("Client Alpha", item.ClientNom); Assert.Equal("Site Beta", item.SiteNom); Assert.Equal("INT-001", item.InterventionReference); Assert.Equal("EXT-7", item.InterventionReferenceExterne); Assert.Equal("DEV-42", item.InterventionNumeroDevis); Assert.Equal("Visite", item.TypeActivite); Assert.Equal("Planifié", item.Statut); } [Fact] public async Task Adresse_Provient_Du_Site_De_LIntervention() { // Arrange : le rendez-vous n'a pas de site direct ; le nom et l'adresse // doivent être résolus depuis le site de l'intervention liée. Guid clientId = Guid.NewGuid(); Guid siteInterventionId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var siteDto = new SiteDetailsDto { Id = siteInterventionId.ToString(), Nom = "Chantier Intervention", ClientId = clientId.ToString(), Adresse = new AddressDto { OneLine = "12 rue du Test, 75000 Paris" } }; await SiteRepository.UpsertAsync(new LocalSite { Id = siteInterventionId, ClientId = clientId, PayloadJson = Serialize(siteDto), UpdatedAtUtc = DateTimeOffset.UtcNow }); await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-002", clientId, siteInterventionId)); await RendezVousRepository.UpsertAsync(CreateRendezVous( rdvId, jour.AddHours(9), jour.AddHours(10), interventionId: interventionId.ToString())); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : nom et adresse proviennent du site de l'intervention. AgendaItem item = Assert.Single(result); Assert.Equal("Chantier Intervention", item.SiteNom); Assert.Equal("12 rue du Test, 75000 Paris", item.SiteAdresse); } [Fact] public async Task ChampsDenormalises_DuRdv_SontUtilises() { // Arrange : un rendez-vous portant déjà les champs dénormalisés résolus // par la jointure SQL serveur (RDV → Intervention → Client/Site/Adresse). DateTime jour = new(2026, 8, 12); Guid rdvId = Guid.NewGuid(); var dto = new RendezVousDetailsDto { Id = rdvId.ToString(), Titre = "RDV enrichi", Debut = jour.AddHours(9), Fin = jour.AddHours(10), InterventionReference = "INT-REF", InterventionNumeroDevis = "DEV-99", InterventionReferenceExterne = "EXT-88", ClientNom = "Client Joint", SiteNom = "Site Joint", SiteAdresse = "12 rue Joint, LYON, 69001" }; await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rdvId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : les champs dénormalisés du rendez-vous sont utilisés directement. AgendaItem item = Assert.Single(result); Assert.Equal("INT-REF", item.InterventionReference); Assert.Equal("DEV-99", item.InterventionNumeroDevis); Assert.Equal("EXT-88", item.InterventionReferenceExterne); Assert.Equal("Client Joint", item.ClientNom); Assert.Equal("Site Joint", item.SiteNom); Assert.Equal("12 rue Joint, LYON, 69001", item.SiteAdresse); } [Fact] public async Task RendezVousAnnule_EstAnnuleVrai() { // Arrange : statut « Annulé ». DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync( CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), statut: "Annulé", estAnnule: true)); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert Assert.True(Assert.Single(result).EstAnnule); } [Fact] public async Task RendezVousStatutConfirmeAvecAnnulation_EstAnnuleFaux() { // Arrange : champs d'annulation présents mais statut remis à « Confirmé ». DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync( CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), statut: "Confirmé", estAnnule: true)); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : le statut fait foi — le rendez-vous n'est plus annulé. Assert.False(Assert.Single(result).EstAnnule); } [Fact] public async Task GetRendezVousById_RetourneLeBonRendezVous() { // Arrange Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(rdvId, jour.AddHours(9), jour.AddHours(10), titre: "Détail")); // Act AgendaItem? item = await _service.GetRendezVousByIdAsync(rdvId); // Assert Assert.NotNull(item); Assert.Equal(rdvId, item.RendezVousGuid); Assert.Equal("Détail", item.Titre); } [Fact] public async Task GetRendezVousById_Introuvable_RetourneNull() { // Act AgendaItem? item = await _service.GetRendezVousByIdAsync(Guid.NewGuid()); // Assert Assert.Null(item); } [Fact] public async Task PayloadInvalide_IgnoreLeRendezVous() { // Arrange await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = Guid.NewGuid(), PayloadJson = "not-json", UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(DateTime.Today); // Assert : le payload invalide est ignoré sans erreur. Assert.Empty(result); } [Fact] public async Task CollaborateursAffectes_SontResolusEtExposes() { // Arrange : deux affectations, l'une portant son nom, l'autre résolue // depuis la table locale Collaborateur. Guid collaborateur1 = Guid.NewGuid(); Guid collaborateur2 = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var collaborateurDto = new CollaborateurDetailsDto { Id = collaborateur2.ToString(), NomAffiche = "Marie Local" }; await CollaborateurRepository.UpsertAsync(new LocalCollaborateur { Id = collaborateur2, PayloadJson = Serialize(collaborateurDto), UpdatedAtUtc = DateTimeOffset.UtcNow }); var dto = new RendezVousDetailsDto { Id = rdvId.ToString(), Titre = "RDV équipe", Debut = jour.AddHours(9), Fin = jour.AddHours(10), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = collaborateur1.ToString(), NomAffiche = "Jean Porté" }, new AffectationCollaborateurDto { CollaborateurId = collaborateur2.ToString() } ] }; await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rdvId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : identités et noms des deux collaborateurs exposés. AgendaItem item = Assert.Single(result); Assert.Equal( new[] { collaborateur1, collaborateur2 }.OrderBy(g => g).ToArray(), item.CollaborateurGuids.OrderBy(g => g).ToArray()); Assert.Contains("Jean Porté", item.Collaborateurs); Assert.Contains("Marie Local", item.Collaborateurs); Assert.Equal("Jean Porté", item.CollaborateurNom); } [Fact] public async Task CollaborateursTempsValides_SontExposesParAffectation() { // Arrange : RDV groupe avec deux affectations, une seule validée. Guid collaborateurValide = Guid.NewGuid(); Guid collaborateurNonValide = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var dto = new RendezVousDetailsDto { Id = rdvId.ToString(), Titre = "RDV équipe", Debut = jour.AddHours(9), Fin = jour.AddHours(10), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = collaborateurValide.ToString(), TempsValide = true }, new AffectationCollaborateurDto { CollaborateurId = collaborateurNonValide.ToString(), TempsValide = false } ] }; await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rdvId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : seul le collaborateur validé est exposé par affectation ; le // drapeau global TempsValide reste « au moins une affectation ». AgendaItem item = Assert.Single(result); Assert.True(item.TempsValide); Assert.Equal(new[] { collaborateurValide }, item.CollaborateursTempsValides); } [Fact] public async Task GetRendezVousPourCollaborateurs_ChargeDepuisApi() { // Arrange Guid collab1 = Guid.NewGuid(); Guid collab2 = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var dto1 = new RendezVousDetailsDto { Id = Guid.NewGuid().ToString(), Titre = "RDV 1", Debut = jour.AddHours(9), Fin = jour.AddHours(10), SiteNom = "Site A", SiteAdresse = "1 rue A", Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = collab1.ToString(), NomAffiche = "Alice" } ] }; var dto2 = new RendezVousDetailsDto { Id = Guid.NewGuid().ToString(), Titre = "RDV 2", Debut = jour.AddHours(11), Fin = jour.AddHours(12), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = collab2.ToString(), NomAffiche = "Bob" } ] }; var api = new FakeRendezVousApiClient([(collab1, dto1), (collab2, dto2)]); var service = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, api, SessionService); // Act : agenda du collaborateur 1 uniquement. IReadOnlyList result = await service.GetRendezVousForCollaborateursAsync([collab1], jour, jour.AddDays(7)); // Assert : seul le rendez-vous du collaborateur ciblé est retourné et enrichi. AgendaItem item = Assert.Single(result); Assert.Equal("RDV 1", item.Titre); Assert.Equal("Site A", item.SiteNom); Assert.Equal("1 rue A", item.SiteAdresse); Assert.Equal(collab1, item.CollaborateurGuids.Single()); } // ===== Nom du collaborateur affecté (annuaire du bootstrap) ===== /// /// Rendez-vous local dont l'affectation ne porte AUCUN nom et dont le /// collaborateur n'existe pas dans la table locale : c'est l'état RÉEL d'un /// appareil mobile, le snapshot ne transportant jamais les collaborateurs en /// bloc (voir MobileSnapshotService). /// private static LocalRendezVous RendezVousSansNomDeCollaborateur( Guid rdvId, DateTime debut, DateTime fin, Guid collaborateurGuid) { var dto = new RendezVousDetailsDto { Id = rdvId.ToString("D"), Titre = "RDV sans nom de collaborateur", Debut = debut, Fin = fin, Statut = new ReferenceValueDto { Label = "Planifié" }, Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = collaborateurGuid.ToString("D") } ] }; return new LocalRendezVous { Id = rdvId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task GetRendezVousForDay_NomAbsentDuSnapshot_EstResoluParLAnnuaireDuBootstrap() { // Arrange : l'annuaire local (bootstrap) connaît le collaborateur. Guid rdvId = Guid.NewGuid(); Guid collaborateurGuid = Guid.NewGuid(); DateTime jour = new(2026, 3, 10, 9, 0, 0); await RendezVousRepository.UpsertAsync( RendezVousSansNomDeCollaborateur(rdvId, jour, jour.AddHours(1), collaborateurGuid)); SessionService.Bootstrap = new LocalBootstrapState { Collaborateurs = [ new CollaborateurSummaryDto { Id = collaborateurGuid.ToString("D"), NomAffiche = "Jean DURAND" } ] }; // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : le nom est résolu SANS appel réseau et sans table locale. AgendaItem item = Assert.Single(result); Assert.Equal("Jean DURAND", item.CollaborateurNom); Assert.Equal("Jean DURAND", Assert.Single(item.Collaborateurs)); } [Fact] public async Task GetRendezVousById_NomAbsentDuSnapshot_EstResoluParLAnnuaireDuBootstrap() { // La fiche rendez-vous emprunte un autre chemin (rafraîchissement serveur // puis repli sur le snapshot local) : le nom doit y être résolu de la même // façon, sinon la fiche affiche un collaborateur vide. Guid rdvId = Guid.NewGuid(); Guid collaborateurGuid = Guid.NewGuid(); DateTime jour = new(2026, 3, 10, 14, 0, 0); await RendezVousRepository.UpsertAsync( RendezVousSansNomDeCollaborateur(rdvId, jour, jour.AddHours(2), collaborateurGuid)); SessionService.Bootstrap = new LocalBootstrapState { Collaborateurs = [ new CollaborateurSummaryDto { Id = collaborateurGuid.ToString("D"), NomAffiche = "Marie MARTIN" } ] }; // Act AgendaItem? item = await _service.GetRendezVousByIdAsync(rdvId); // Assert Assert.NotNull(item); Assert.Equal("Marie MARTIN", item!.CollaborateurNom); } [Fact] public async Task GetRendezVousForDay_NomPorteParUneEquipeDuBootstrap_EstUtilise() { // L'annuaire plat peut être vide alors que les équipes portent les noms. Guid rdvId = Guid.NewGuid(); Guid collaborateurGuid = Guid.NewGuid(); DateTime jour = new(2026, 3, 11, 8, 0, 0); await RendezVousRepository.UpsertAsync( RendezVousSansNomDeCollaborateur(rdvId, jour, jour.AddHours(1), collaborateurGuid)); SessionService.Bootstrap = new LocalBootstrapState { Equipes = [ new EquipeDto { Collaborateurs = [ new CollaborateurSummaryDto { Id = collaborateurGuid.ToString("D"), NomAffiche = "Paul PETIT" } ] } ] }; // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert Assert.Equal("Paul PETIT", Assert.Single(result).CollaborateurNom); } [Fact] public async Task GetRendezVousForDay_NomIntrouvable_ResteVideSansNomInvente() { // Arrange : ni affectation nommée, ni table locale, ni annuaire. Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 3, 12, 9, 0, 0); await RendezVousRepository.UpsertAsync( RendezVousSansNomDeCollaborateur(rdvId, jour, jour.AddHours(1), Guid.NewGuid())); SessionService.Bootstrap = null; // Act IReadOnlyList result = await _service.GetRendezVousForDayAsync(jour); // Assert : aucun identifiant technique n'est affiché à la place d'un nom. AgendaItem item = Assert.Single(result); Assert.Null(item.CollaborateurNom); Assert.Empty(item.Collaborateurs); Assert.Equal(rdvId, item.RendezVousGuid); } private sealed class FakeRendezVousApiClient : StubMobileApiClient { private readonly IReadOnlyList<(Guid CollaborateurGuid, RendezVousDetailsDto Dto)> _rdvs; public FakeRendezVousApiClient(IReadOnlyList<(Guid CollaborateurGuid, RendezVousDetailsDto Dto)> rdvs) { _rdvs = rdvs; } public override Task> GetRendezVousByCollaborateurAsync( Guid collaborateurGuid, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) { return Task.FromResult>( _rdvs.Where(r => r.CollaborateurGuid == collaborateurGuid).Select(r => r.Dto).ToList()); } public override Task> GetRendezVousByCollaborateursAsync( IReadOnlyList collaborateurGuids, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) { var set = collaborateurGuids.ToHashSet(); return Task.FromResult>( _rdvs.Where(r => set.Contains(r.CollaborateurGuid)).Select(r => r.Dto).ToList()); } } }