369 lines
16 KiB
C#
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);
|
|
}
|
|
}
|