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

546 lines
22 KiB
C#

using System.Net.Http;
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.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests du ViewModel de la liste des dossiers : chargement, états vide/erreur,
/// six critères de recherche (Titre, Client, Statut, Type, Référence externe,
/// Référence interne), épinglage local, reset et navigation. La recherche est
/// déléguée au service (côté serveur) : le fake filtre les données locales
/// comme le ferait la requête SQL LIKE.
///
/// Couvre aussi la politique d'erreur de la recherche : elle atteint l'API sans
/// garde intermédiaire (<c>DossierLocalService.SearchDossiersAsync</c> →
/// <c>_apiClient</c>), donc un échec réseau — ticket de production du 14/10,
/// « Connection failure » — doit partir en Warning/P2, jamais en Error/P1.
/// </summary>
[Collection("TelemetrieStatique")]
public class DossiersViewModelTests : LocalTestBase
{
private readonly DossiersViewModel _viewModel;
private readonly FakeDossierService _service;
private readonly StubParametreMetierService _parametreMetier = new();
private readonly NavigationService _navigation;
public DossiersViewModelTests()
{
var inner = new DossierLocalService(DossierRepository, ClientRepository, DossierStateRepository, DossierInterventionCacheRepository, ApiClient);
_service = new FakeDossierService(inner);
_navigation = new NavigationService();
_viewModel = new DossiersViewModel(_service, _parametreMetier, _navigation);
}
private static LocalDossier CreateDossier(
Guid id,
string reference,
string titre,
Guid? clientId = null,
string? statut = null,
string? type = null,
string? refInterne = null,
string? refExterne = null,
int nbInterventions = 0)
{
var dto = new DossierDetailsDto
{
Id = id.ToString(),
ReferenceMetier = reference,
Titre = titre,
ClientId = clientId?.ToString(),
Statut = statut is null ? null : new ReferenceValueDto { Label = statut },
Type = type is null ? null : new ReferenceValueDto { Label = type },
RefInterne = refInterne,
RefExterne = refExterne,
NbInterventionsNonArchivees = nbInterventions
};
return new LocalDossier
{
Id = id,
ClientId = clientId,
PayloadJson = JsonSerializer.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 = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
[Fact]
public async Task Load_NAfficheAucunResultatAvantRecherche()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A"));
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.False(_viewModel.IsLoading);
Assert.False(_viewModel.HasDossiers);
Assert.Empty(_viewModel.Dossiers);
Assert.False(_viewModel.HasSearched);
Assert.True(_viewModel.IsSearchNotStarted);
}
[Fact]
public async Task Load_AucunDossier_Empty()
{
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.False(_viewModel.IsLoading);
Assert.False(_viewModel.HasDossiers);
Assert.True(_viewModel.IsSearchNotStarted);
Assert.Null(_viewModel.ErrorMessage);
}
[Fact]
public async Task FiltreTitre_InsensibleCasse()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.TitreText = "alpha";
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("Alpha", item.Titre);
Assert.Equal("alpha", _service.LastTitre);
}
[Fact]
public async Task FiltreStatut()
{
_parametreMetier.DossierStatuts = [new() { Label = "Ouvert" }, new() { Label = "Clôturé" }];
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", statut: "Ouvert"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", statut: "Clôturé"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.SelectedStatutIndex = _viewModel.StatutOptions.ToList().IndexOf("Ouvert");
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", item.Reference);
}
[Fact]
public async Task FiltreType()
{
_parametreMetier.DossierTypes = [new() { Label = "Maintenance" }, new() { Label = "Installation" }];
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", type: "Maintenance"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", type: "Installation"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.SelectedTypeIndex = _viewModel.TypeOptions.ToList().IndexOf("Installation");
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-002", item.Reference);
}
[Fact]
public async Task FiltreClient_ParNomHumain()
{
Guid clientAlpha = Guid.NewGuid();
Guid clientBeta = Guid.NewGuid();
await ClientRepository.UpsertAsync(CreateClient(clientAlpha, "Client Alpha"));
await ClientRepository.UpsertAsync(CreateClient(clientBeta, "Client Beta"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", clientId: clientAlpha));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", clientId: clientBeta));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.ClientText = "Client Alpha";
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", item.Reference);
}
[Fact]
public async Task FiltreRefInterne()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", refInterne: "INT-42"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", refInterne: "INT-7"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.RefInterneText = "42";
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", item.Reference);
Assert.Equal("42", _service.LastRefInterne);
}
[Fact]
public async Task FiltreRefExterne()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", refExterne: "EXT-42"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", refExterne: "EXT-7"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.RefExterneText = "42";
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", item.Reference);
}
[Fact]
public async Task Reset_RetourneEtatInitialSansResultat()
{
_parametreMetier.DossierStatuts = [new() { Label = "Ouvert" }, new() { Label = "Clôturé" }];
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha", statut: "Ouvert"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta", statut: "Clôturé"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.TitreText = "Alpha";
_viewModel.SelectedStatutIndex = _viewModel.StatutOptions.ToList().IndexOf("Ouvert");
await _viewModel.SearchCommand.ExecuteAsync(null);
Assert.Single(_viewModel.Dossiers);
Assert.True(_viewModel.HasActiveFilters);
_viewModel.ResetFiltersCommand.Execute(null);
Assert.Empty(_viewModel.Dossiers);
Assert.False(_viewModel.HasSearched);
Assert.True(_viewModel.IsSearchNotStarted);
Assert.False(_viewModel.HasActiveFilters);
Assert.Empty(_viewModel.TitreText);
Assert.Equal(0, _viewModel.SelectedStatutIndex);
}
[Fact]
public async Task Pin_Persiste_EtFiltreEpingles()
{
Guid id = Guid.NewGuid();
await DossierRepository.UpsertAsync(CreateDossier(id, "DOS-001", "A"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B"));
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.SearchCommand.ExecuteAsync(null);
// Épingler DOS-001.
var item = _viewModel.Dossiers.First(d => d.Reference == "DOS-001");
await _viewModel.TogglePinnedCommand.ExecuteAsync(item);
// L'état épinglé est persisté (relecture depuis le repository local).
var pinned = await DossierStateRepository.GetPinnedIdsAsync();
Assert.Contains(id, pinned);
// Onglet « Épinglés » : seule DOS-001 reste.
_viewModel.ShowPinnedCommand.Execute(null);
var only = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", only.Reference);
// Retour « Récentes » : les résultats serveur (2) sont restaurés.
_viewModel.ShowRecentCommand.Execute(null);
Assert.Equal(2, _viewModel.Dossiers.Count);
}
[Fact]
public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta"));
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.SearchCommand.ExecuteAsync(null);
// La collection affichée sert de témoin : un rechargement la remplacerait.
var collection = _viewModel.Dossiers;
var item = _viewModel.Dossiers[0];
await _viewModel.TogglePinnedCommand.ExecuteAsync(item);
Assert.True(item.IsPinned);
Assert.Same(collection, _viewModel.Dossiers);
Assert.Equal(2, _viewModel.Dossiers.Count);
// Et l'état est bien persisté (relecture depuis le repository local).
Assert.Contains(item.DossierGuid, await DossierStateRepository.GetPinnedIdsAsync());
}
[Fact]
public async Task EtatVide_SansDonneesEtSansResultat_AfficheAucunResultat()
{
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.True(_viewModel.IsSearchNotStarted);
Assert.False(_viewModel.HasNoResults);
await _viewModel.SearchCommand.ExecuteAsync(null);
Assert.True(_viewModel.HasSearched);
Assert.True(_viewModel.HasNoResults);
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.TitreText = "introuvable";
await _viewModel.SearchCommand.ExecuteAsync(null);
Assert.True(_viewModel.HasSearched);
Assert.True(_viewModel.HasNoResults);
}
[Fact]
public async Task ChangementCritereSansRecherche_NeModifiePasLaListe()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.TitreText = "Alpha";
Assert.False(_viewModel.HasSearched);
Assert.Empty(_viewModel.Dossiers);
Assert.True(_viewModel.IsSearchNotStarted);
}
[Fact]
public async Task RechercheVide_AfficheTousLesNonArchives()
{
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha"));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta"));
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.SearchCommand.ExecuteAsync(null);
Assert.True(_viewModel.HasSearched);
Assert.Equal(2, _viewModel.Dossiers.Count);
}
[Fact]
public async Task EpinglesSansRecherche_AfficheFavorisPuisRetourEtatInitial()
{
Guid id = Guid.NewGuid();
await DossierRepository.UpsertAsync(CreateDossier(id, "DOS-001", "Alpha"));
await DossierStateRepository.SetPinnedAsync(
id,
true,
JsonSerializer.Serialize(new DossierListItem { DossierGuid = id, Reference = "DOS-001", Titre = "Alpha" }));
await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta"));
await _viewModel.LoadCommand.ExecuteAsync(null);
_viewModel.ShowPinnedCommand.Execute(null);
var only = Assert.Single(_viewModel.Dossiers);
Assert.Equal("DOS-001", only.Reference);
_viewModel.ShowRecentCommand.Execute(null);
Assert.Empty(_viewModel.Dossiers);
Assert.False(_viewModel.HasSearched);
Assert.True(_viewModel.IsSearchNotStarted);
}
[Fact]
public async Task CardMapping_TypeAbsentMasqueEtReferencesPropagees()
{
await DossierRepository.UpsertAsync(CreateDossier(
Guid.NewGuid(),
"DOS-001",
"",
type: "Aucun Type de dossier",
refInterne: "INT-42",
refExterne: "EXT-42"));
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.SearchCommand.ExecuteAsync(null);
var item = Assert.Single(_viewModel.Dossiers);
Assert.Null(item.Type);
Assert.Null(item.Titre);
Assert.Equal("INT-42", item.RefInterne);
Assert.Equal("EXT-42", item.RefExterne);
}
/// <summary>
/// Fake de service : délègue au service local réel (lecture SQLite) mais
/// implémente <see cref="SearchDossiersAsync"/> par un filtre en mémoire des
/// données locales, simulant la requête SQL LIKE côté serveur.
/// </summary>
private sealed class FakeDossierService : IDossierLocalService
{
private readonly IDossierLocalService _inner;
public string? LastRefInterne { get; private set; }
public string? LastTitre { get; private set; }
public string? LastClient { get; private set; }
public string? LastStatut { get; private set; }
public string? LastType { get; private set; }
public string? LastRefExterne { get; private set; }
public FakeDossierService(IDossierLocalService inner) => _inner = inner;
public Task<IReadOnlyList<DossierListItem>> GetDossiersAsync(CancellationToken ct = default)
=> _inner.GetDossiersAsync(ct);
/// <summary>Simule un échec de la recherche serveur (réseau ou métier).</summary>
public Exception? SearchException { get; set; }
public async Task<IReadOnlyList<DossierListItem>> SearchDossiersAsync(
string? refInterne,
string? titre,
string? client,
string? statut,
string? type,
string? refExterne,
CancellationToken ct = default)
{
if (SearchException is not null)
{
throw SearchException;
}
LastRefInterne = refInterne;
LastTitre = titre;
LastClient = client;
LastStatut = statut;
LastType = type;
LastRefExterne = refExterne;
IReadOnlyList<DossierListItem> all = await _inner.GetDossiersAsync(ct);
IEnumerable<DossierListItem> filtered = all;
if (!string.IsNullOrWhiteSpace(refInterne))
{
filtered = filtered.Where(d => Contains(d.RefInterne, refInterne));
}
if (!string.IsNullOrWhiteSpace(titre))
{
filtered = filtered.Where(d => Contains(d.Titre, titre));
}
if (!string.IsNullOrWhiteSpace(client))
{
filtered = filtered.Where(d => Contains(d.ClientNom, client));
}
if (!string.IsNullOrWhiteSpace(statut))
{
filtered = filtered.Where(d => string.Equals(d.Statut, statut, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(type))
{
filtered = filtered.Where(d => string.Equals(d.Type, type, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(refExterne))
{
filtered = filtered.Where(d => Contains(d.RefExterne, refExterne));
}
return filtered.ToList();
}
public Task<IReadOnlyList<DossierListItem>> GetLastSearchAsync(CancellationToken ct = default)
=> _inner.GetLastSearchAsync(ct);
public Task ClearLastSearchAsync(CancellationToken ct = default)
=> _inner.ClearLastSearchAsync(ct);
private static bool Contains(string? value, string query)
=> value is not null && value.Contains(query, StringComparison.OrdinalIgnoreCase);
public Task<DossierDetail?> GetDossierByIdAsync(Guid dossierGuid, CancellationToken ct = default)
=> _inner.GetDossierByIdAsync(dossierGuid, ct);
public Task SetPinnedAsync(Guid dossierGuid, bool isPinned, DossierListItem? item, CancellationToken ct = default)
=> _inner.SetPinnedAsync(dossierGuid, isPinned, item, ct);
public Task<IReadOnlyList<DossierListItem>> GetPinnedAsync(CancellationToken ct = default)
=> _inner.GetPinnedAsync(ct);
public Task<IReadOnlyList<DossierIntervention>> GetDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> _inner.GetDossierInterventionsAsync(dossierGuid, ct);
public Task<IReadOnlyList<DossierIntervention>> RefreshDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> _inner.RefreshDossierInterventionsAsync(dossierGuid, ct);
}
/// <summary>
/// Ticket de production du 14/10 (« Connection failure ») : l'échec réseau de la
/// recherche n'est pas un incident. L'événement part en Warning/P2, l'écran n'entre
/// dans aucun groupe d'erreurs du Diagnostic Center, et l'utilisateur reçoit le
/// libellé français stable plutôt qu'un message technique anglais.
/// </summary>
[Fact]
public async Task Search_EchecReseau_SignaleEnWarningSansIncident()
{
_service.SearchException = new HttpRequestException("Connection failure");
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await _viewModel.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Equal("Hors ligne — impossible de contacter le serveur.", _viewModel.ErrorMessage);
DiagnosticEvent evt = EvenementDossiers(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
AssertAucunIncident(telemetry);
}
/// <summary>
/// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1.
/// Le classement ne dégrade pas les vraies erreurs.
/// </summary>
[Fact]
public async Task Search_ErreurMetier_ResteUnIncident()
{
_service.SearchException = new InvalidOperationException("échec de recherche");
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await _viewModel.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
DiagnosticEvent evt = EvenementDossiers(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
/// <summary>Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent).</summary>
private static DiagnosticEvent EvenementDossiers(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(DossiersViewModel));
/// <summary>Aucun événement de diagnostic de cet écran en Error/Critical.</summary>
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(DossiersViewModel) && e.Level == TelemetryLevel.Error);
/// <summary>Capture des événements de la télémétrie statique globale.</summary>
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}