using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; namespace Omika.Tests.Mobile.Local; /// /// Tests du service local d'annulation d'un rendez-vous : mise à jour SQLite /// (champs d'annulation du DTO, jamais de DELETE) + enqueue Outbox UPDATE. /// public class RendezVousAnnulationLocalServiceTests { private sealed class FakeRendezVousRepository : ILocalRepository { public string TableName => "RendezVous"; private readonly Dictionary _items = []; public void Seed(LocalRendezVous entity) => _items[entity.Id] = entity; public Task UpsertAsync(LocalRendezVous entity, CancellationToken cancellationToken = default) { _items[entity.Id] = entity; return Task.CompletedTask; } public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => Task.FromResult(_items.TryGetValue(id, out LocalRendezVous? entity) ? entity : null); public Task> GetAllAsync(CancellationToken cancellationToken = default) => Task.FromResult>([.. _items.Values]); public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { _items.Remove(id); return Task.CompletedTask; } public Task ClearAsync(CancellationToken cancellationToken = default) { _items.Clear(); return Task.CompletedTask; } public Task CountAsync(CancellationToken cancellationToken = default) => Task.FromResult(_items.Count); } private sealed class FakeOutboxService : IOutboxService { public List<(string EntityType, SyncOperationType OperationType, Guid EntityGuid, string Payload)> Enqueued { get; } = []; public Task EnqueueAsync( string entityType, SyncOperationType operationType, Guid requestedEntityGuid, string payload, string? dependencyEntityType = null, Guid? dependencyRequestedEntityGuid = null, CancellationToken cancellationToken = default) { Enqueued.Add((entityType, operationType, requestedEntityGuid, payload)); return Task.FromResult(Guid.NewGuid()); } public Task CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(Enqueued.Count); public Task CountNotYetSentAsync(CancellationToken cancellationToken = default) => Task.FromResult(Enqueued.Count); } private static RendezVousDetailsDto DtoSansAnnulation(Guid guid) => new() { Id = guid.ToString("D"), Titre = "RDV Client", Debut = DateTime.Now.AddDays(7), Fin = DateTime.Now.AddDays(7).AddHours(2) }; /// DTO d'un rendez-vous GROUPÉ : deux collaborateurs, une description. private static RendezVousDetailsDto DtoGroupe(Guid guid, params string[] collaborateurs) => new() { Id = guid.ToString("D"), Titre = "RDV groupé", Description = "Description initiale", Debut = DateTime.Now.AddDays(7), Fin = DateTime.Now.AddDays(7).AddHours(2), EstGroupe = true, Collaborateurs = collaborateurs .Select(id => new AffectationCollaborateurDto { CollaborateurId = id, Statut = new Omika.Core.Contracts.Common.ReferenceValueDto { Key = "S.RDV.CONF", Label = "Confirmé" } }) .ToList() }; [Fact] public async Task AnnulerAsync_AppliqueAnnulationLocaleEtEnqueueUpdate() { // Arrange : un rendez-vous local sans annulation. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(DtoSansAnnulation(rendezVousGuid)) }); var outbox = new FakeOutboxService(); var notifier = new RendezVousStateNotifier(); RendezVousStateChange? published = null; notifier.StateChanged += (_, change) => published = change; var service = new RendezVousAnnulationLocalService(repository, outbox, notifier); // Act Guid collaborateurGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); await service.AnnulerAsync(rendezVousGuid, collaborateurGuid, "O.ANU.RDV.E.CLI", "M.ANU.RDV.ABS", "Note collaborateur"); // Assert : la copie locale porte l'annulation ET le statut Annulé (GUID conservé). LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); Assert.NotNull(updated); RendezVousDetailsDto? dto = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.NotNull(dto?.Annulation); Assert.Equal("O.ANU.RDV.E.CLI", dto!.Annulation!.Origine!.Key); Assert.Equal("M.ANU.RDV.ABS", dto.Annulation.Motif!.Key); Assert.Equal(RendezVousAnnulationLocalService.StatutAnnuleKey, dto.Statut?.Key); // Assert : une opération Outbox UPDATE a été enqueuée (jamais DELETE). var enqueued = Assert.Single(outbox.Enqueued); Assert.Equal("RendezVous", enqueued.EntityType); Assert.Equal(SyncOperationType.Update, enqueued.OperationType); Assert.Equal(rendezVousGuid, enqueued.EntityGuid); RendezVousAnnulationPayload? payload = JsonSerializer.Deserialize(enqueued.Payload); Assert.NotNull(payload); Assert.Equal("O.ANU.RDV.E.CLI", payload!.OrigineKey); Assert.Equal("M.ANU.RDV.ABS", payload.MotifKey); Assert.Equal("Note collaborateur", payload.NoteCollaborateur); Assert.Equal(collaborateurGuid.ToString(), payload.CollaborateurId); Assert.Equal("Annulation", payload.Kind); // Assert : le changement d'état a été publié (propagation aux vues). Assert.NotNull(published); Assert.Equal(rendezVousGuid, published!.RendezVousGuid); Assert.True(published.EstAnnule); Assert.Equal(RendezVousAnnulationLocalService.StatutAnnuleKey, published.StatutKey); } [Fact] public async Task AnnulerAsync_LibellesFournis_SontConservesDansLaCopieLocale() { // Arrange : un rendez-vous local sans annulation. Guid rendezVousGuid = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(DtoSansAnnulation(rendezVousGuid)) }); var service = new RendezVousAnnulationLocalService( repository, new FakeOutboxService(), new RendezVousStateNotifier()); // Act : les libellés du référentiel local sont fournis par l'appelant. await service.AnnulerAsync( rendezVousGuid, null, "O.ANU.RDV.E.CLI", "M.ANU.RDV.ABS", "Client absent", "Jean DUPONT", "Externe : Client", "Absent"); // Assert : la copie locale porte le LIBELLÉ (pas seulement le code du // paramètre), pour que la fiche ouverte hors ligne reste lisible. LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); RendezVousDetailsDto? dto = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.Equal("O.ANU.RDV.E.CLI", dto!.Annulation!.Origine!.Key); Assert.Equal("Externe : Client", dto.Annulation.Origine!.Label); Assert.Equal("M.ANU.RDV.ABS", dto.Annulation.Motif!.Key); Assert.Equal("Absent", dto.Annulation.Motif!.Label); // Assert : la description porte la mention « Note (Nom) : message ». Assert.Equal("Note (Jean DUPONT) : Client absent", dto.Description); } [Fact] public async Task AnnulerAsync_SansLibelleFourni_NePerdPasLeLibelleDejaConnu() { // Arrange : la copie locale porte DÉJÀ les libellés (rendez-vous chargé // depuis l'API). L'annulation ne doit pas les remplacer par la seule clé : // la fiche afficherait alors le code du paramètre au lieu de son libellé. Guid rendezVousGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); RendezVousDetailsDto dto = DtoSansAnnulation(rendezVousGuid) with { Description = "Description initiale", Annulation = new AnnulationRendezVousDto { Origine = new Omika.Core.Contracts.Common.ReferenceValueDto { Key = "O.ANU.RDV.E.CLI", Label = "Externe : Client" }, Motif = new Omika.Core.Contracts.Common.ReferenceValueDto { Key = "M.ANU.RDV.ABS", Label = "Absent" } } }; var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(dto) }); var service = new RendezVousAnnulationLocalService( repository, new FakeOutboxService(), new RendezVousStateNotifier()); // Act : aucun libellé transmis par l'appelant. await service.AnnulerAsync(rendezVousGuid, null, "O.ANU.RDV.E.CLI", "M.ANU.RDV.ABS", null); // Assert : les libellés déjà connus sont CONSERVÉS (jamais écrasés par la clé). LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); RendezVousDetailsDto? resultat = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.Equal("Externe : Client", resultat!.Annulation!.Origine!.Label); Assert.Equal("Absent", resultat.Annulation.Motif!.Label); // Assert : sans message du collaborateur, la description reste INCHANGÉE. Assert.Equal("Description initiale", resultat.Description); } [Fact] public async Task AnnulerAsync_SansLibelleNiLibelleConnu_ConserveLaCleSansInventerDeLibelle() { // Arrange : copie locale sans annulation, aucun libellé fourni. Guid rendezVousGuid = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(DtoSansAnnulation(rendezVousGuid)) }); var service = new RendezVousAnnulationLocalService( repository, new FakeOutboxService(), new RendezVousStateNotifier()); // Act await service.AnnulerAsync(rendezVousGuid, null, "O.ANU.RDV.E.CLI", "M.ANU.RDV.ABS", null); // Assert : dernier repli, la clé métier est conservée (jamais de mention // vide) et aucun libellé n'est inventé. LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); RendezVousDetailsDto? resultat = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.Equal("O.ANU.RDV.E.CLI", resultat!.Annulation!.Origine!.Key); Assert.Equal("O.ANU.RDV.E.CLI", resultat.Annulation.Origine!.Label); Assert.Equal("M.ANU.RDV.ABS", resultat.Annulation.Motif!.Label); } [Fact] public async Task AnnulerAsync_RendezVousLocalIntrouvable_EnqueueToutDeMemeLOutbox() { // Arrange : aucun rendez-vous dans le snapshot local (ex. agenda d'équipe). Guid rendezVousGuid = Guid.NewGuid(); var outbox = new FakeOutboxService(); var service = new RendezVousAnnulationLocalService( new FakeRendezVousRepository(), outbox, new RendezVousStateNotifier()); // Act : l'annulation ne doit PAS lever d'exception. await service.AnnulerAsync(rendezVousGuid, null, null, "M.ANU.RDV.ABS", null); // Assert : l'opération Outbox UPDATE est enqueuée malgré l'absence locale. var enqueued = Assert.Single(outbox.Enqueued); Assert.Equal("RendezVous", enqueued.EntityType); Assert.Equal(SyncOperationType.Update, enqueued.OperationType); Assert.Equal(rendezVousGuid, enqueued.EntityGuid); } [Fact] public async Task AnnulerAsync_RendezVousGroupe_AnnuleToutesLesAffectationsEtConcateneLaDescription() { // Arrange : rendez-vous groupé (2 collaborateurs, 1 noyau partagé). Guid rendezVousGuid = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); string collaborateurA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; string collaborateurB = "dddddddd-dddd-dddd-dddd-dddddddddddd"; var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(DtoGroupe(rendezVousGuid, collaborateurA, collaborateurB)) }); var outbox = new FakeOutboxService(); var service = new RendezVousAnnulationLocalService(repository, outbox, new RendezVousStateNotifier()); // Act await service.AnnulerAsync( rendezVousGuid, Guid.Parse(collaborateurA), "O.ANU.RDV.E.CLI", "M.ANU.RDV.ABS", "Client absent", "Jean DUPONT"); // Assert : TOUTES les affectations du groupe sont annulées. LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); RendezVousDetailsDto? dto = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.NotNull(dto); Assert.True(dto!.EstGroupe); Assert.Equal(2, dto.Collaborateurs.Count); Assert.All(dto.Collaborateurs, a => Assert.Equal(RendezVousAnnulationLocalService.StatutAnnuleKey, a.Statut?.Key)); // Assert : la description porte la mention d'annulation « Note (Nom) : message ». Assert.Equal("Description initiale" + Environment.NewLine + "Note (Jean DUPONT) : Client absent", dto.Description); // Assert : le payload Outbox porte le nom du collaborateur (concaténé côté serveur). RendezVousAnnulationPayload? payload = JsonSerializer.Deserialize( Assert.Single(outbox.Enqueued).Payload); Assert.NotNull(payload); Assert.Equal("Jean DUPONT", payload!.CollaborateurNom); Assert.Equal(collaborateurA, payload.CollaborateurId); } [Fact] public async Task AnnulerAsync_NoteDejaPresente_NeLaDupliquePas() { // Arrange : rendez-vous déjà annulé sur cet appareil (rejeu hors ligne). Guid rendezVousGuid = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); RendezVousDetailsDto dto = DtoSansAnnulation(rendezVousGuid); dto = dto with { Description = "Description initiale" + Environment.NewLine + "Note (Jean DUPONT) : Client absent" }; var repository = new FakeRendezVousRepository(); repository.Seed(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(dto) }); var service = new RendezVousAnnulationLocalService( repository, new FakeOutboxService(), new RendezVousStateNotifier()); // Act await service.AnnulerAsync( rendezVousGuid, null, "O1", "M1", "Client absent", "Jean DUPONT"); // Assert : la description n'est pas dupliquée. LocalRendezVous? updated = await repository.GetByIdAsync(rendezVousGuid); RendezVousDetailsDto? resultat = JsonSerializer.Deserialize(updated!.PayloadJson); Assert.Equal("Description initiale" + Environment.NewLine + "Note (Jean DUPONT) : Client absent", resultat!.Description); } }