433 lines
18 KiB
C#
433 lines
18 KiB
C#
using System.Text.Json;
|
|
using Omika.App.Local.Abstractions;
|
|
using Omika.App.Local.Models;
|
|
using Omika.App.Local.Services;
|
|
using Omika.App.Navigation;
|
|
using Omika.App.ViewModels;
|
|
using Omika.Core.Contracts.Common;
|
|
using Omika.Core.Contracts.Interventions;
|
|
using Omika.Core.Contracts.Mobile.Sync;
|
|
using Omika.Core.Contracts.Organisation;
|
|
|
|
namespace Omika.Tests.Mobile.Local;
|
|
|
|
/// <summary>
|
|
/// Tests du ViewModel du formulaire partagé Intervention (modes Create / Edit).
|
|
/// Vérifie l'ouverture en mode Create (formulaire vide), la validation qui
|
|
/// appelle exclusivement IInterventionLocalService (CreateAsync / UpdateAsync),
|
|
/// l'annulation sans mutation, le préremplissage en mode Edit, la protection
|
|
/// contre le double clic et le fonctionnement 100 % offline (aucun serveur).
|
|
/// </summary>
|
|
public class InterventionEditViewModelTests : LocalTestBase
|
|
{
|
|
private readonly InterventionEditViewModel _viewModel;
|
|
private readonly NavigationService _navigation;
|
|
private readonly IInterventionLocalService _interventionService;
|
|
|
|
public InterventionEditViewModelTests()
|
|
{
|
|
_interventionService = new InterventionLocalService(
|
|
InterventionRepository, ClientRepository, SiteRepository, DossierRepository,
|
|
RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository,
|
|
OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database);
|
|
|
|
IClientLocalService clientService = new ClientLocalService(
|
|
ClientRepository, DossierRepository, ContextInterventionCacheRepository, ClientStateRepository, ApiClient);
|
|
ISiteLocalService siteService = new SiteLocalService(
|
|
SiteRepository, ClientRepository, PersonneRepository, RendezVousRepository, InterventionRepository,
|
|
ContextInterventionCacheRepository, SiteStateRepository, ApiClient);
|
|
IDossierLocalService dossierService = new DossierLocalService(
|
|
DossierRepository, ClientRepository,
|
|
DossierStateRepository, DossierInterventionCacheRepository, ApiClient);
|
|
ICollaborateurLocalService collaborateurService = new CollaborateurLocalService(
|
|
CollaborateurRepository, RendezVousRepository, ContextInterventionCacheRepository, ApiClient, SessionService);
|
|
|
|
_navigation = new NavigationService();
|
|
_viewModel = new InterventionEditViewModel(
|
|
_interventionService,
|
|
clientService,
|
|
siteService,
|
|
dossierService,
|
|
collaborateurService,
|
|
InterventionRepository,
|
|
_navigation);
|
|
}
|
|
|
|
private static LocalIntervention CreateIntervention(
|
|
Guid id,
|
|
string reference,
|
|
string? titre = null,
|
|
string? description = null,
|
|
DateTime? debut = null,
|
|
DateTime? fin = null,
|
|
Guid? clientId = null,
|
|
Guid? siteId = null,
|
|
Guid? dossierId = null,
|
|
IReadOnlyList<Guid>? collaborateurIds = null)
|
|
{
|
|
var dto = new InterventionDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Reference = reference,
|
|
Titre = titre,
|
|
Description = description,
|
|
DatePrevueDebut = debut,
|
|
DatePrevueFin = fin,
|
|
ClientId = clientId?.ToString(),
|
|
SiteId = siteId?.ToString(),
|
|
DossierId = dossierId?.ToString(),
|
|
CollaborateurIds = collaborateurIds?.Select(g => g.ToString()).ToList() ?? []
|
|
};
|
|
return new LocalIntervention
|
|
{
|
|
Id = id,
|
|
ClientId = clientId,
|
|
SiteId = siteId,
|
|
DossierId = dossierId,
|
|
PayloadJson = JsonSerializer.Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalClient CreateClient(Guid id, string nom)
|
|
{
|
|
var dto = new Omika.Core.Contracts.Clients.ClientDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Nom = nom
|
|
};
|
|
return new LocalClient
|
|
{
|
|
Id = id,
|
|
PayloadJson = JsonSerializer.Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalSite CreateSite(Guid id, string nom)
|
|
{
|
|
var dto = new Omika.Core.Contracts.Sites.SiteDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Nom = nom
|
|
};
|
|
return new LocalSite
|
|
{
|
|
Id = id,
|
|
PayloadJson = JsonSerializer.Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalDossier CreateDossier(Guid id, string reference)
|
|
{
|
|
var dto = new DossierDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
ReferenceMetier = reference
|
|
};
|
|
return new LocalDossier
|
|
{
|
|
Id = id,
|
|
PayloadJson = JsonSerializer.Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalCollaborateur CreateCollaborateur(Guid id, string nom, string prenom)
|
|
{
|
|
var dto = new CollaborateurDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Nom = nom,
|
|
Prenom = prenom
|
|
};
|
|
return new LocalCollaborateur
|
|
{
|
|
Id = id,
|
|
PayloadJson = JsonSerializer.Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static InterventionEditNavigation CreateNavigation(InterventionEditMode mode, Guid guid)
|
|
=> new(mode, guid, new NavigationContext(AppPage.Interventions, null));
|
|
|
|
[Fact]
|
|
public async Task OpenCreate_FormulaireVide_ModeCreate()
|
|
{
|
|
// Act
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
|
|
// Assert : mode création, formulaire vide, aucune entité créée.
|
|
Assert.True(_viewModel.IsCreateMode);
|
|
Assert.False(_viewModel.IsEditMode);
|
|
Assert.Null(_viewModel.Reference);
|
|
Assert.Null(_viewModel.Titre);
|
|
Assert.Null(_viewModel.Description);
|
|
Assert.Null(_viewModel.ErrorMessage);
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_Create_CreeLocalementEtNavigueDetail()
|
|
{
|
|
// Arrange
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
_viewModel.Reference = "INT-NEW";
|
|
_viewModel.Titre = "Nouvelle intervention";
|
|
_viewModel.Description = "Description";
|
|
|
|
// Act
|
|
await _viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Assert : navigation vers le détail de la nouvelle intervention (GUID public).
|
|
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
|
|
Guid newGuid = Assert.IsType<Guid>(_navigation.CurrentParameter);
|
|
Assert.NotEqual(Guid.Empty, newGuid);
|
|
|
|
// Assert : une seule entité locale créée avec le GUID public (A).
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(newGuid);
|
|
Assert.NotNull(local);
|
|
var dto = JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson);
|
|
Assert.Equal("INT-NEW", dto!.Reference);
|
|
Assert.Equal("Nouvelle intervention", dto.Titre);
|
|
|
|
// Assert : une seule opération Outbox CREATE.
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
var op = Assert.Single(ready);
|
|
Assert.Equal(SyncOperationType.Create, op.OperationType);
|
|
Assert.Equal(newGuid, op.RequestedEntityGuid);
|
|
Assert.Equal("Intervention", op.EntityType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_Create_SansTitre_ErreurValidation()
|
|
{
|
|
// Arrange
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
|
|
// Act : aucun titre renseigné.
|
|
await _viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Assert : erreur de validation, aucune mutation ni Outbox.
|
|
Assert.NotNull(_viewModel.ErrorMessage);
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
Assert.Equal(AppPage.Home, _navigation.CurrentPage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_Create_AucuneMutation_RetourContexte()
|
|
{
|
|
// Arrange
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
_viewModel.Titre = "Intervention annulée";
|
|
|
|
// Act
|
|
_viewModel.CancelCommand.Execute(null);
|
|
|
|
// Assert : aucune entité locale ni Outbox créée, retour au contexte.
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
Assert.Empty(await InterventionRepository.GetAllAsync());
|
|
Assert.Equal(AppPage.Interventions, _navigation.CurrentPage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenEdit_DonneesReellesPreRemplies()
|
|
{
|
|
// Arrange : une intervention locale existante avec relations.
|
|
Guid clientId = Guid.NewGuid();
|
|
Guid siteId = Guid.NewGuid();
|
|
Guid dossierId = Guid.NewGuid();
|
|
Guid collaborateurId = Guid.NewGuid();
|
|
Guid interventionId = Guid.NewGuid();
|
|
|
|
await ClientRepository.UpsertAsync(CreateClient(clientId, "Client Alpha"));
|
|
await SiteRepository.UpsertAsync(CreateSite(siteId, "Site Beta"));
|
|
await DossierRepository.UpsertAsync(CreateDossier(dossierId, "DOS-001"));
|
|
await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean"));
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(
|
|
interventionId, "INT-001", "Titre existant", "Description existante",
|
|
new DateTime(2026, 9, 1, 9, 0, 0), new DateTime(2026, 9, 1, 12, 0, 0),
|
|
clientId, siteId, dossierId, [collaborateurId]));
|
|
|
|
// Act
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
|
|
|
|
// Assert : mode édition et champs préremplis avec les vraies données.
|
|
Assert.True(_viewModel.IsEditMode);
|
|
Assert.False(_viewModel.IsCreateMode);
|
|
Assert.Equal("INT-001", _viewModel.Reference);
|
|
Assert.Equal("Titre existant", _viewModel.Titre);
|
|
Assert.Equal("Description existante", _viewModel.Description);
|
|
Assert.Equal(new DateTime(2026, 9, 1, 9, 0, 0), _viewModel.DatePrevueDebut);
|
|
Assert.Equal(clientId, _viewModel.SelectedClient!.ClientGuid);
|
|
Assert.Equal(siteId, _viewModel.SelectedSite!.SiteGuid);
|
|
Assert.Equal(dossierId, _viewModel.SelectedDossier!.DossierGuid);
|
|
Assert.Single(_viewModel.SelectedCollaborateurs);
|
|
Assert.Equal(collaborateurId, _viewModel.SelectedCollaborateurs[0].CollaborateurGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_Edit_ModifieLocalementEtRetourDetail()
|
|
{
|
|
// Arrange : une intervention locale existante.
|
|
Guid interventionId = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", "Avant"));
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
|
|
|
|
// Act : modification du titre.
|
|
_viewModel.Titre = "Après";
|
|
await _viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Assert : retour au détail de la même intervention.
|
|
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
|
|
Assert.Equal(interventionId, Assert.IsType<Guid>(_navigation.CurrentParameter));
|
|
|
|
// Assert : l'entité locale est mise à jour.
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(interventionId);
|
|
Assert.NotNull(local);
|
|
Assert.Equal("Après", JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson)?.Titre);
|
|
|
|
// Assert : une seule opération Outbox UPDATE.
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
var op = Assert.Single(ready);
|
|
Assert.Equal(SyncOperationType.Update, op.OperationType);
|
|
Assert.Equal(interventionId, op.RequestedEntityGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_Edit_AucuneModification()
|
|
{
|
|
// Arrange : une intervention locale existante.
|
|
Guid interventionId = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", "Original"));
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
|
|
|
|
// Act : modification puis annulation.
|
|
_viewModel.Titre = "Modifié mais annulé";
|
|
_viewModel.CancelCommand.Execute(null);
|
|
|
|
// Assert : aucune mutation locale ni Outbox, données inchangées.
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(interventionId);
|
|
Assert.Equal("Original", JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson)?.Titre);
|
|
Assert.Equal(AppPage.Interventions, _navigation.CurrentPage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DoubleSave_PremierEnCours_DeuxiemeIgnore()
|
|
{
|
|
// Arrange : fake contrôlable qui bloque le premier CreateAsync sur un
|
|
// TaskCompletionSource, afin d'observer le verrouillage IsBusy réel.
|
|
var blockingService = new BlockingInterventionLocalService();
|
|
var viewModel = new InterventionEditViewModel(
|
|
blockingService,
|
|
new ClientLocalService(ClientRepository, DossierRepository, ContextInterventionCacheRepository, ClientStateRepository, ApiClient),
|
|
new SiteLocalService(SiteRepository, ClientRepository, PersonneRepository, RendezVousRepository, InterventionRepository, ContextInterventionCacheRepository, SiteStateRepository, ApiClient),
|
|
new DossierLocalService(DossierRepository, ClientRepository, DossierStateRepository, DossierInterventionCacheRepository, ApiClient),
|
|
new CollaborateurLocalService(CollaborateurRepository, RendezVousRepository, ContextInterventionCacheRepository, ApiClient, SessionService),
|
|
InterventionRepository,
|
|
_navigation);
|
|
|
|
await viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
viewModel.Titre = "Intervention";
|
|
|
|
// Act : premier Save lancé, reste volontairement en attente sur le TCS.
|
|
Task firstSave = viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Attendre que le premier appel CreateAsync soit réellement en cours.
|
|
await blockingService.WaitForCreateAsync();
|
|
|
|
// Assert : IsBusy est true pendant que le premier Save est en cours.
|
|
Assert.True(viewModel.IsBusy);
|
|
Assert.Equal(1, blockingService.CreateCallCount);
|
|
|
|
// Act : deuxième Save déclenché pendant que le premier est bloqué.
|
|
Task secondSave = viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Assert : le deuxième clic ne provoque pas un deuxième appel au service.
|
|
Assert.Equal(1, blockingService.CreateCallCount);
|
|
|
|
// Act : compléter le premier Save.
|
|
blockingService.CompleteCreate(Guid.NewGuid());
|
|
|
|
await firstSave;
|
|
await secondSave;
|
|
|
|
// Assert : une seule mutation, IsBusy repassé à false.
|
|
Assert.Equal(1, blockingService.CreateCallCount);
|
|
Assert.False(viewModel.IsBusy);
|
|
}
|
|
[Fact]
|
|
public async Task FonctionneOffline_SansServeur()
|
|
{
|
|
// Arrange : aucune dépendance réseau, uniquement SQLite local.
|
|
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
|
|
_viewModel.Titre = "Intervention offline";
|
|
|
|
// Act
|
|
await _viewModel.SaveCommand.ExecuteAsync(null);
|
|
|
|
// Assert : création locale réussie sans serveur.
|
|
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
|
|
Assert.Single(await InterventionRepository.GetAllAsync());
|
|
Assert.Single(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake contrôlable de <see cref="IInterventionLocalService"/> : le premier
|
|
/// appel CreateAsync reste en attente sur un TaskCompletionSource jusqu'à ce
|
|
/// que le test le complète. Permet d'observer le verrouillage IsBusy réel du
|
|
/// ViewModel lors d'un double clic, de façon déterministe (aucun Task.Delay).
|
|
/// </summary>
|
|
sealed class BlockingInterventionLocalService : IInterventionLocalService
|
|
{
|
|
private readonly TaskCompletionSource<Guid> _createGate =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly TaskCompletionSource _createStarted =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>Nombre d'appels CreateAsync réellement reçus.</summary>
|
|
public int CreateCallCount { get; private set; }
|
|
|
|
public async Task<Guid> CreateAsync(
|
|
InterventionSyncPayload payload,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
CreateCallCount++;
|
|
_createStarted.TrySetResult();
|
|
await _createGate.Task;
|
|
return Guid.NewGuid();
|
|
}
|
|
|
|
public Task<Guid> UpdateAsync(
|
|
Guid interventionGuid,
|
|
InterventionSyncPayload payload,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(interventionGuid);
|
|
|
|
public Task<Guid> ArchiveAsync(
|
|
Guid interventionGuid,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(interventionGuid);
|
|
|
|
public Task<InterventionDetail?> GetInterventionByIdAsync(
|
|
Guid interventionGuid,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<InterventionDetail?>(null);
|
|
|
|
public Task<IReadOnlyList<InterventionListItem>> GetInterventionsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<InterventionListItem>>([]);
|
|
|
|
/// <summary>Attend que le premier CreateAsync soit réellement en cours.</summary>
|
|
public Task WaitForCreateAsync() => _createStarted.Task;
|
|
|
|
/// <summary>Complète le premier CreateAsync avec le résultat fourni.</summary>
|
|
public void CompleteCreate(Guid result) => _createGate.TrySetResult(result);
|
|
}
|
|
|
|
|