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);
}
}