Files
Omika/tests/Omika.Tests/Mobile/Local/RendezVousAnnulationLocalServiceTests.cs
T
2026-10-23 18:46:50 +02:00

369 lines
16 KiB
C#

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;
/// <summary>
/// Tests du service local d'annulation d'un rendez-vous : mise à jour SQLite
/// (champs d'annulation du DTO, jamais de DELETE) + enqueue Outbox UPDATE.
/// </summary>
public class RendezVousAnnulationLocalServiceTests
{
private sealed class FakeRendezVousRepository : ILocalRepository<LocalRendezVous>
{
public string TableName => "RendezVous";
private readonly Dictionary<Guid, LocalRendezVous> _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<LocalRendezVous?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_items.TryGetValue(id, out LocalRendezVous? entity) ? entity : null);
public Task<IReadOnlyList<LocalRendezVous>> GetAllAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<LocalRendezVous>>([.. _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<int> 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<Guid> 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<int> CountPendingAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Enqueued.Count);
public Task<int> 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)
};
/// <summary>DTO d'un rendez-vous GROUPÉ : deux collaborateurs, une description.</summary>
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<RendezVousDetailsDto>(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<RendezVousAnnulationPayload>(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<RendezVousDetailsDto>(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<RendezVousDetailsDto>(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<RendezVousDetailsDto>(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<RendezVousDetailsDto>(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<RendezVousAnnulationPayload>(
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<RendezVousDetailsDto>(updated!.PayloadJson);
Assert.Equal("Description initiale" + Environment.NewLine + "Note (Jean DUPONT) : Client absent", resultat!.Description);
}
}