Files
2026-10-23 18:46:50 +02:00

650 lines
24 KiB
C#

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;
/// <summary>
/// Tests du service Agenda : lecture locale SQLite, filtrage par journée,
/// enrichissement Client/Site/Intervention, tri chronologique et détail.
/// </summary>
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>(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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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) =====
/// <summary>
/// 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 <c>MobileSnapshotService</c>).
/// </summary>
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<AgendaItem> 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<AgendaItem> 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<AgendaItem> 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<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousByCollaborateurAsync(
Guid collaborateurGuid,
DateTime debutMin,
DateTime debutMax,
CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>(
_rdvs.Where(r => r.CollaborateurGuid == collaborateurGuid).Select(r => r.Dto).ToList());
}
public override Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousByCollaborateursAsync(
IReadOnlyList<Guid> collaborateurGuids,
DateTime debutMin,
DateTime debutMax,
CancellationToken cancellationToken = default)
{
var set = collaborateurGuids.ToHashSet();
return Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>(
_rdvs.Where(r => set.Contains(r.CollaborateurGuid)).Select(r => r.Dto).ToList());
}
}
}