using System.Net.Http;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.ViewModels;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Configuration;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Organisation;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
///
/// Tests du CompteRenduCreateViewModel : vérifie que la création transmet
/// l'identité du collaborateur connecté (audit CreationUser/ModifUser) et que les
/// échecs réseau de cet écran — dont celui de l'anti-doublon survenu en production
/// le 09/10 (« Connection failure ») — sont classés en Warning/P2, jamais en
/// incident Error/P1.
///
[Collection("TelemetrieStatique")]
public class CompteRenduCreateViewModelTests
{
private sealed class FakeInterventionLocalService : IInterventionLocalService
{
public CreateCompteRenduRequest? LastRequest { get; private set; }
public Task CreateAsync(Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task UpdateAsync(Guid interventionGuid, Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task ArchiveAsync(Guid interventionGuid, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken cancellationToken = default)
=> Task.FromResult(null);
public Task> GetInterventionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult>([]);
public Task CreateCompteRenduAsync(
Guid rendezVousGuid,
CreateCompteRenduRequest request,
CancellationToken cancellationToken = default)
{
LastRequest = request;
return Task.FromResult(new CompteRenduDto { Id = Guid.NewGuid().ToString() });
}
}
private sealed class FakeInterventionContactsService : IInterventionContactsService
{
public Task> GetContactsForInterventionAsync(
Guid interventionGuid, CancellationToken cancellationToken = default)
=> Task.FromResult>([]);
public Task GetContactByIdAsync(
Guid contactGuid, CancellationToken cancellationToken = default)
=> Task.FromResult(null);
public Task> GetContactsDisponiblesAsync(
Guid interventionGuid, CancellationToken cancellationToken = default)
=> Task.FromResult>([]);
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
/// Référentiel « type de signataire » exposé par le bootstrap local.
public IReadOnlyList TypesSignataire { get; set; } = [];
public Task> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult(null);
public Task GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default)
=> Task.FromResult(null);
public Task GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default)
=> Task.FromResult(null);
public Task> GetInterventionTypesAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetInterventionEtatsAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetDossierTypesAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetDossierStatutsAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetClientTypesAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetClientStatutsAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetSiteTypesAsync(CancellationToken ct = default)
=> Task.FromResult>([]);
public Task> GetTypeSignatairesAsync(CancellationToken ct = default)
=> Task.FromResult(TypesSignataire);
}
private sealed class FakeCompteRenduScopeService : ICompteRenduScopeService
{
/// Exception levée par la lecture du compte rendu (chargement de l'écran).
public Exception? ExceptionOnGet { get; set; }
/// Exception levée par la vérification d'existence (anti-doublon du bouton Créer).
public Exception? ExceptionOnExists { get; set; }
public CompteRenduScope ResolveScope(AgendaItem rendezVous)
=> new(rendezVous.RendezVousGuid, DateOnly.FromDateTime(rendezVous.Debut), rendezVous.InterventionGuid);
public Task GetCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> ExceptionOnGet is { } ex
? Task.FromException(ex)
: Task.FromResult(null);
public Task CompteRenduExistsAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> ExceptionOnExists is { } ex
? Task.FromException(ex)
: Task.FromResult(false);
public Task CompteRenduIsValidatedAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(false);
public Task CanCreateCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(true);
}
[Fact]
public async Task Save_TransmetLeCollaborateurConnectePourAudit()
{
// Arrange : un collaborateur connecté (bootstrap) et un rendez-vous passé.
Guid collaborateurGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var session = new StubSessionService
{
Bootstrap = new LocalBootstrapState
{
Collaborateur = new CollaborateurDetailsDto
{
Id = collaborateurGuid.ToString("D"),
NomAffiche = "Technicien"
}
}
};
var intervention = new FakeInterventionLocalService();
var vm = new CompteRenduCreateViewModel(
intervention,
new FakeInterventionContactsService(),
session,
new FakeParametreMetierService(),
new NavigationService(),
new FakeCompteRenduScopeService());
DateTime passe = DateTime.Now.AddDays(-1);
var rdv = new AgendaItem
{
RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Titre = "RDV",
Debut = passe.AddHours(10),
Fin = passe.AddHours(12),
InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd")
};
// Act : chargement puis enregistrement (création du compte rendu).
await vm.LoadCommand.ExecuteAsync(rdv);
await vm.SaveCommand.ExecuteAsync(null);
// Assert : l'identité du collaborateur est transmise (jamais null/0).
Assert.NotNull(intervention.LastRequest);
Assert.Equal(collaborateurGuid.ToString("D"), intervention.LastRequest!.CollaborateurId);
}
///
/// Le référentiel « type de signataire » est proposé dans la liste déroulante
/// du compte rendu (source unique de CR_CleTypeSignataire).
///
[Fact]
public async Task Load_ExposeLeReferentielDesTypesDeSignataire()
{
FakeParametreMetierService parametres = TypesSignataireParDefaut();
var vm = CreateViewModel(parametres);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
Assert.Equal(2, vm.TypesSignataire.Count);
Assert.False(vm.AucunTypeSignataire);
}
///
/// Quand le rôle de la personne correspond au référentiel, le type est
/// pré-sélectionné ET transmis à la création (colonne renseignée).
///
[Fact]
public async Task Save_TypeDeSignataireDeduit_EstTransmis()
{
var intervention = new FakeInterventionLocalService();
var vm = CreateViewModel(TypesSignataireParDefaut(), intervention);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
// Le client est assimilé au « Propriétaire » du référentiel local.
Assert.NotNull(vm.SelectedTypeSignataire);
Assert.Equal("T.SIGN.PROP", vm.SelectedTypeSignataire!.Key);
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(intervention.LastRequest);
Assert.Equal("T.SIGN.PROP", intervention.LastRequest!.TypeSignataire?.Key);
}
///
/// Cas du bug constaté : aucun rôle ne correspond au référentiel (le type
/// n'est pas déductible). L'utilisateur choisit alors explicitement le type
/// dans la liste déroulante, et cette valeur est transmise.
///
[Fact]
public async Task Save_TypeDeSignataireNonDeduit_ObligeLeChoixPuisLeTransmet()
{
var intervention = new FakeInterventionLocalService();
var parametres = new FakeParametreMetierService
{
// Aucun libellé ne correspond au rôle « Client » : rien n'est déduit.
TypesSignataire =
[
new ReferenceValueDto { Key = "T.SIGN.MAND", Label = "Mandataire" },
new ReferenceValueDto { Key = "T.SIGN.OCC", Label = "Occupant" }
]
};
var vm = CreateViewModel(parametres, intervention);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
Assert.Null(vm.SelectedTypeSignataire);
Assert.True(vm.TypeSignataireManquant);
// Sans choix, la création est bloquée avec un message explicite : le
// compte rendu ne doit pas partir avec un type de signataire vide.
await vm.SaveCommand.ExecuteAsync(null);
Assert.Null(intervention.LastRequest);
Assert.Equal("Veuillez sélectionner le type de signataire.", vm.ErrorMessage);
// Choix explicite dans la liste déroulante.
vm.SelectedTypeSignataire = vm.TypesSignataire[1];
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(intervention.LastRequest);
Assert.Equal("T.SIGN.OCC", intervention.LastRequest!.TypeSignataire?.Key);
}
///
/// Référentiel vide (paramétrage absent du bootstrap) : la création n'est pas
/// bloquée — aucune valeur n'est inventée, mais l'utilisateur est informé.
///
[Fact]
public async Task Save_ReferentielVide_NeBloquePasLaCreation()
{
var intervention = new FakeInterventionLocalService();
var vm = CreateViewModel(new FakeParametreMetierService(), intervention);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
Assert.True(vm.AucunTypeSignataire);
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(intervention.LastRequest);
Assert.Null(intervention.LastRequest!.TypeSignataire);
}
private static FakeParametreMetierService TypesSignataireParDefaut()
=> new()
{
TypesSignataire =
[
new ReferenceValueDto { Key = "T.SIGN.PROP", Label = "Propriétaire" },
new ReferenceValueDto { Key = "T.SIGN.LOC", Label = "Locataire" }
]
};
private static AgendaItem CreateRendezVous()
{
DateTime passe = DateTime.Now.AddDays(-1);
return new AgendaItem
{
RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Titre = "RDV",
Debut = passe.AddHours(10),
Fin = passe.AddHours(12),
// Un compte rendu documente TOUJOURS une intervention : les rendez-vous
// de ces tests en portent une, comme en production.
InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
ClientNom = "Client Test"
};
}
private static CompteRenduCreateViewModel CreateViewModel(
FakeParametreMetierService parametres,
FakeInterventionLocalService? intervention = null,
FakeCompteRenduScopeService? scope = null)
{
var session = new StubSessionService
{
Bootstrap = new LocalBootstrapState
{
Collaborateur = new CollaborateurDetailsDto
{
Id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
NomAffiche = "Technicien"
}
}
};
return new CompteRenduCreateViewModel(
intervention ?? new FakeInterventionLocalService(),
new FakeInterventionContactsService(),
session,
parametres,
new NavigationService(),
scope ?? new FakeCompteRenduScopeService());
}
///
/// Règle métier : le compte rendu ne se saisit qu'APRÈS le rendez-vous. Un
/// rendez-vous futur n'ouvre aucune saisie (aucun champ affiché, aucune
/// création possible) : la page explique la règle et propose le retour.
///
[Fact]
public async Task Load_RendezVousFutur_AucuneSaisiePossible()
{
// Arrange : rendez-vous planifié demain.
DateTime demain = DateTime.Now.AddDays(1);
var rendezVous = new AgendaItem
{
RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Titre = "RDV futur",
Debut = demain,
Fin = demain.AddHours(1),
InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
ClientNom = "Client Test"
};
var intervention = new FakeInterventionLocalService();
CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), intervention: intervention);
// Act
await vm.LoadCommand.ExecuteAsync(rendezVous);
// Assert : formulaire masqué, explication datée.
Assert.False(vm.SaisieAutorisee);
Assert.True(vm.SaisieIndisponible);
Assert.False(vm.FormulaireVisible);
Assert.Contains("Rendez-vous prévu le", vm.MessageSaisieIndisponible);
Assert.Null(vm.ErrorMessage);
// Act : enregistrement demandé malgré tout (appel indirect de la commande).
await vm.SaveCommand.ExecuteAsync(null);
// Assert : aucune création n'est transmise au serveur.
Assert.Null(intervention.LastRequest);
Assert.False(vm.IsBusy);
}
///
/// Règle métier : un compte rendu documente une intervention (travaux
/// réalisés, réserves, signature du client). Un rendez-vous lié à AUCUNE
/// intervention ne peut donc pas en porter un : la page n'ouvre aucune saisie,
/// explique la règle et ne transmet jamais de création.
///
[Fact]
public async Task Load_SansIntervention_SaisieFermee()
{
// Arrange : rendez-vous passé, sans intervention liée.
DateTime passe = DateTime.Now.AddDays(-1);
var rendezVous = new AgendaItem
{
RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Titre = "RDV sans intervention",
Debut = passe.AddHours(10),
Fin = passe.AddHours(12),
ClientNom = "Client Test"
};
var intervention = new FakeInterventionLocalService();
CompteRenduCreateViewModel vm = CreateViewModel(
TypesSignataireParDefaut(), intervention: intervention);
// Act
await vm.LoadCommand.ExecuteAsync(rendezVous);
// Assert : formulaire masqué, motif dédié (ce n'est pas la règle « après le
// rendez-vous » : le rendez-vous est passé).
Assert.False(vm.SaisieAutorisee);
Assert.True(vm.SaisieIndisponible);
Assert.False(vm.FormulaireVisible);
Assert.Contains("intervention", vm.ExplicationSaisieIndisponible);
Assert.Null(vm.MessageSaisieIndisponible);
// Act : enregistrement demandé malgré tout (appel indirect de la commande).
await vm.SaveCommand.ExecuteAsync(null);
// Assert : aucune création n'est transmise au serveur.
Assert.Null(intervention.LastRequest);
Assert.False(vm.IsBusy);
}
///
/// Contre-épreuve de la règle : un rendez-vous passé ouvre la saisie
/// normalement (aucune explication, formulaire affiché).
///
[Fact]
public async Task Load_RendezVousPasse_SaisieOuverte()
{
CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut());
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
Assert.True(vm.SaisieAutorisee);
Assert.True(vm.FormulaireVisible);
Assert.False(vm.SaisieIndisponible);
Assert.Null(vm.MessageSaisieIndisponible);
}
///
/// Échec réseau au chargement de l'écran : l'utilisateur reçoit le libellé français
/// « hors ligne », et l'événement part en Warning/P2 — aucune entrée en Error/P1,
/// donc aucun alimentation des groupes d'erreurs du Diagnostic Center.
///
[Fact]
public async Task Load_EchecReseau_EstSignaleEnWarningSansIncident()
{
var scope = new FakeCompteRenduScopeService
{
ExceptionOnGet = new HttpRequestException("Connection failure")
};
CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage);
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementCompteRendu(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
}
///
/// Régression du ticket de production du 09/10 : l'échec réseau de la vérification
/// d'existence (anti-doublon du bouton Créer) remontait en Error/P1. Il doit
/// désormais être classé en Warning/P2, à sa vraie gravité.
///
[Fact]
public async Task Save_EchecReseauDeLAntiDoublon_NEstPasUnIncident()
{
var scope = new FakeCompteRenduScopeService
{
ExceptionOnExists = new HttpRequestException("Connection failure")
};
CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SaveCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementCompteRendu(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage);
}
///
/// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1 avec
/// son message — le classement préalable ne dilue pas les vraies erreurs.
///
[Fact]
public async Task Save_ErreurMetier_ResteUnIncident()
{
var scope = new FakeCompteRenduScopeService
{
ExceptionOnExists = new InvalidOperationException("boom")
};
CompteRenduCreateViewModel vm = CreateViewModel(TypesSignataireParDefaut(), scope: scope);
await vm.LoadCommand.ExecuteAsync(CreateRendezVous());
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SaveCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Equal("boom", vm.ErrorMessage);
DiagnosticEvent evt = EvenementCompteRendu(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
///
/// Seul événement de diagnostic attribué à ce ViewModel : la télémétrie d'usage
/// (fonctionnalités) est un , pas un
/// . Le filtre porte sur le composant, la télémétrie
/// statique globale étant partagée avec les classes exécutées en parallèle.
///
private static DiagnosticEvent EvenementCompteRendu(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType(),
e => e.Component == nameof(CompteRenduCreateViewModel));
/// Aucun événement de diagnostic de cet écran en Error/Critical.
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType(),
e => e.Component == nameof(CompteRenduCreateViewModel) && e.Level == TelemetryLevel.Error);
///
/// Capture en mémoire des événements de la télémétrie statique globale
/// (), rétablie à null après
/// chaque test — la classe appartient à la collection « TelemetrieStatique ».
///
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}