using System.Text.Json; using Omika.App.Local.Models; using Omika.App.Local.Repositories; using Omika.App.Local.Services; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; namespace Omika.Tests.Mobile.Local; /// /// Tests du service local SuiviTemps (lecture snapshot SQLite + création /// offline-first). Vérifie la lecture, le tri, l'ignorance des payloads /// invalides, la conservation du GUID public, le filtre par intervention et la /// création offline-first (écriture locale + Outbox CREATE, Valide toujours /// false). /// public class SuiviTempsLocalServiceTests : LocalTestBase { private readonly SuiviTempsLocalService _service; public SuiviTempsLocalServiceTests() { _service = new SuiviTempsLocalService( SuiviTempsRepository, OutboxService, ApiClient, SuiviTempsDraftRepository, RendezVousTempsValidationRepository, new RendezVousDraftReferenceRepository(Database), SessionService); } private static string Serialize(T value) => JsonSerializer.Serialize(value); private static LocalSuiviTemps CreateSuiviTemps( Guid id, DateTime date, int dureeMinutes, Guid? interventionId = null, Guid? collaborateurId = null, Guid? rendezVousId = null, string? description = null) { var dto = new SuiviTempsDto { Id = id.ToString(), Date = date, DureeMinutes = dureeMinutes, InterventionId = interventionId?.ToString(), CollaborateurId = collaborateurId?.ToString(), RendezVousId = rendezVousId?.ToString(), Description = description, TypeTemps = new ReferenceValueDto { Label = "Déplacement" }, Valide = true }; return new LocalSuiviTemps { Id = id, InterventionId = interventionId, CollaborateurId = collaborateurId, RendezVousId = rendezVousId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task GetAll_Aucun_ListeVide() { // Act IReadOnlyList result = await _service.GetAllAsync(); // Assert Assert.Empty(result); } [Fact] public async Task GetAll_Un_RetourneLaListe() { // Arrange Guid id = Guid.NewGuid(); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(id, new DateTime(2026, 8, 10), 60)); // Act IReadOnlyList result = await _service.GetAllAsync(); // Assert var item = Assert.Single(result); Assert.Equal(id.ToString(), item.Id); Assert.Equal(60, item.DureeMinutes); } [Fact] public async Task GetAll_Plusieurs_TrieParDatePuisDuree() { // Arrange await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 15), 30)); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 10), 90)); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 10), 45)); // Act IReadOnlyList result = await _service.GetAllAsync(); // Assert : tri déterministe par date puis durée. Assert.Equal(3, result.Count); Assert.Equal(45, result[0].DureeMinutes); Assert.Equal(90, result[1].DureeMinutes); Assert.Equal(30, result[2].DureeMinutes); } [Fact] public async Task GetAll_PayloadInvalide_Ignore() { // Arrange await SuiviTempsRepository.UpsertAsync(new LocalSuiviTemps { Id = Guid.NewGuid(), PayloadJson = "not-json", UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetAllAsync(); // Assert : l'entrée invalide est ignorée sans erreur. Assert.Empty(result); } [Fact] public async Task GetByGuid_Trouve_RetourneLeDto() { // Arrange Guid id = Guid.NewGuid(); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(id, new DateTime(2026, 8, 10), 60)); // Act SuiviTempsDto? result = await _service.GetByGuidAsync(id); // Assert Assert.NotNull(result); Assert.Equal(id.ToString(), result!.Id); } [Fact] public async Task GetByGuid_Introuvable_RetourneNull() { // Act SuiviTempsDto? result = await _service.GetByGuidAsync(Guid.NewGuid()); // Assert Assert.Null(result); } [Fact] public async Task GetByIntervention_FiltreParIntervention() { // Arrange Guid interventionA = Guid.NewGuid(); Guid interventionB = Guid.NewGuid(); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 10), 60, interventionId: interventionA)); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 11), 30, interventionId: interventionA)); await SuiviTempsRepository.UpsertAsync(CreateSuiviTemps(Guid.NewGuid(), new DateTime(2026, 8, 12), 90, interventionId: interventionB)); // Act IReadOnlyList result = await _service.GetByInterventionAsync(interventionA); // Assert : seuls les suivis de l'intervention A sont retournés. Assert.Equal(2, result.Count); Assert.All(result, s => Assert.Equal(interventionA.ToString(), s.InterventionId)); } [Fact] public async Task CreateAsync_EcritLocalementEtEnqueueOutboxCreate() { // Arrange var payload = new SuiviTempsSyncPayload { CollaborateurId = Guid.NewGuid().ToString(), InterventionId = Guid.NewGuid().ToString(), Date = new DateTime(2026, 9, 1, 9, 0, 0), DureeMinutes = 45, TypeTempsKey = "DEPLACEMENT", Description = "Déplacement client" }; // Act Guid requestedGuid = await _service.CreateAsync(payload); // Assert : l'entité locale est créée avec le GUID public (A). LocalSuiviTemps? local = await SuiviTempsRepository.GetByIdAsync(requestedGuid); Assert.NotNull(local); SuiviTempsDto? dto = JsonSerializer.Deserialize(local!.PayloadJson); Assert.NotNull(dto); Assert.Equal(45, dto!.DureeMinutes); Assert.Equal(payload.InterventionId, dto.InterventionId); Assert.Equal(payload.CollaborateurId, dto.CollaborateurId); // Assert : Valide est toujours false (le mobile ne peut pas le fixer). Assert.False(dto.Valide); // Assert : une opération Outbox CREATE est enqueue (RequestedEntityGuid = A). IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); var op = Assert.Single(ready); Assert.Equal(SyncOperationType.Create, op.OperationType); Assert.Equal(requestedGuid, op.RequestedEntityGuid); Assert.Equal("SuiviTemps", op.EntityType); } [Fact] public async Task CreateAsync_DeuxCreations_DeuxOperationIdsDistincts() { // Arrange var payloadA = new SuiviTempsSyncPayload { Date = new DateTime(2026, 9, 1), DureeMinutes = 30 }; var payloadB = new SuiviTempsSyncPayload { Date = new DateTime(2026, 9, 2), DureeMinutes = 60 }; // Act Guid guidA = await _service.CreateAsync(payloadA); Guid guidB = await _service.CreateAsync(payloadB); // Assert : deux identités mobiles distinctes et deux opérations Outbox. Assert.NotEqual(guidA, guidB); IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); Assert.Equal(2, ready.Count); Assert.Equal(2, ready.Select(o => o.OperationId).Distinct().Count()); } [Fact] public async Task ValidateRendezVous_EnvoieBrouillonsPuisMarqueTempsValide() { // Arrange : un brouillon en attente rattaché au rendez-vous, et un // collaborateur courant dans le bootstrap (nécessaire au TempsValide). Guid rendezVousGuid = Guid.NewGuid(); Guid collaborateurGuid = Guid.NewGuid(); Guid interventionGuid = Guid.NewGuid(); SessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurGuid.ToString("D") } }; await SuiviTempsDraftRepository.UpsertAsync(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = "T.TEMPS.INTER", CollaborateurId = collaborateurGuid, InterventionId = interventionGuid, Date = new DateTime(2026, 9, 1, 9, 0, 0), DureeMinutes = 60, EstEnvoye = false }); // Act await _service.ValidateRendezVousAsync(rendezVousGuid); // Assert : le brouillon est marqué envoyé. IReadOnlyList drafts = await SuiviTempsDraftRepository.GetByRendezVousAsync(rendezVousGuid); Assert.True(Assert.Single(drafts).EstEnvoye); // Assert : une opération Outbox SuiviTemps CREATE et une RendezVous UPDATE. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); OutboxOperation create = Assert.Single( ready, o => o.EntityType == "SuiviTemps" && o.OperationType == SyncOperationType.Create); // L'identité mobile de la saisie (RequestedEntityGuid) est un GUID neuf, // distinct du rendez-vous qui porte la saisie. Assert.NotEqual(rendezVousGuid, create.RequestedEntityGuid); Assert.Contains(ready, o => o.EntityType == "RendezVous" && o.OperationType == SyncOperationType.Update); // Assert : le marqueur local de validation est posé. Assert.True(await _service.IsValideAsync(rendezVousGuid)); } [Fact] public async Task ValidateRendezVous_SansBrouillon_NEnvoiePasDeSuiviTemps() { // Arrange : aucun brouillon. Seul le marquage TempsValide doit partir. Guid rendezVousGuid = Guid.NewGuid(); Guid collaborateurGuid = Guid.NewGuid(); SessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurGuid.ToString("D") } }; // Act await _service.ValidateRendezVousAsync(rendezVousGuid); // Assert : aucune opération SuiviTemps CREATE, mais une RendezVous UPDATE. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); Assert.DoesNotContain(ready, o => o.EntityType == "SuiviTemps"); Assert.Contains(ready, o => o.EntityType == "RendezVous" && o.OperationType == SyncOperationType.Update); } }