310 lines
11 KiB
C#
310 lines
11 KiB
C#
using Omika.App.Local.Abstractions;
|
|
using Omika.App.Local.Models;
|
|
using Omika.App.ViewModels;
|
|
using Omika.Core.Observability;
|
|
|
|
namespace Omika.Tests.Mobile.Local;
|
|
|
|
/// <summary>
|
|
/// Tests du LicenseActivationViewModel : saisie manuelle ET QR sont deux entrées
|
|
/// distinctes vers la même procédure d'inscription. Le QR ne remplit jamais le
|
|
/// champ manuel ; le champ manuel n'est jamais utilisé comme stockage du QR.
|
|
///
|
|
/// Couvre aussi la journalisation des échecs de scan : une DÉFAILLANCE technique
|
|
/// (ouverture/énumération de la caméra en échec) est un incident Error/P1 et doit
|
|
/// être visible dans le Diagnostic Center ; une INDISPONIBILITÉ attendue (repli
|
|
/// plateforme, permission refusée, appareil sans caméra arrière) n'en est pas un.
|
|
/// </summary>
|
|
[Collection("TelemetrieStatique")]
|
|
public class LicenseActivationViewModelTests
|
|
{
|
|
private sealed class FakeEnrollmentService : IEnrollmentService
|
|
{
|
|
public EnrollmentResult EnrollToReturn { get; set; } = EnrollmentResult.Failed("Inscription refusée.");
|
|
public bool ThrowHttp { get; set; }
|
|
public string? LastQrContent { get; private set; }
|
|
|
|
public Task<MobileEnrollmentConfiguration?> LoadAsync(CancellationToken ct = default)
|
|
=> Task.FromResult<MobileEnrollmentConfiguration?>(null);
|
|
|
|
public Task<EnrollmentResult> EnrollAsync(string qrContent, CancellationToken ct = default)
|
|
{
|
|
LastQrContent = qrContent;
|
|
if (ThrowHttp)
|
|
{
|
|
throw new System.Net.Http.HttpRequestException("Network");
|
|
}
|
|
return Task.FromResult(EnrollToReturn);
|
|
}
|
|
|
|
public Task ClearAsync(CancellationToken ct = default) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class FakeQrScanner : IEnrollmentQrScanner
|
|
{
|
|
public string? ScanResult { get; set; }
|
|
|
|
/// <summary>Échec du scanner à simuler (défaillance technique ou indisponibilité).</summary>
|
|
public QrScanException? ScanException { get; set; }
|
|
|
|
public Task<string?> ScanAsync(IQrScannerPreviewSink? previewSink, CancellationToken ct = default)
|
|
=> ScanException is { } ex
|
|
? Task.FromException<string?>(ex)
|
|
: Task.FromResult(ScanResult);
|
|
}
|
|
|
|
private readonly FakeEnrollmentService _enrollment = new();
|
|
private readonly FakeQrScanner _scanner = new();
|
|
private readonly LicenseActivationViewModel _vm;
|
|
|
|
public LicenseActivationViewModelTests()
|
|
{
|
|
_vm = new LicenseActivationViewModel(_enrollment, _scanner);
|
|
}
|
|
|
|
// ===== Saisie manuelle =====
|
|
|
|
[Fact]
|
|
public async Task Enroll_SansCode_AfficheErreur()
|
|
{
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.NotNull(_vm.ErrorMessage);
|
|
Assert.Contains("saisir", _vm.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
Assert.False(_vm.IsEnrolled);
|
|
Assert.Null(_enrollment.LastQrContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_Manuel_EnvoieLaValeurSaisieEtBascule()
|
|
{
|
|
// Test 2 : ManualCode = "ABC" + clic S'inscrire → EnrollAsync reçoit "ABC".
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
|
|
{
|
|
OmikaApiUrl = "https://api.example.com"
|
|
});
|
|
_vm.ManualEnrollmentCode = "ABC";
|
|
bool raised = false;
|
|
_vm.Enrolled += () => raised = true;
|
|
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("ABC", _enrollment.LastQrContent);
|
|
Assert.True(_vm.IsEnrolled);
|
|
Assert.True(raised);
|
|
Assert.Null(_vm.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_Manuel_TrimLaValeur()
|
|
{
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
|
|
{
|
|
OmikaApiUrl = "https://api.example.com"
|
|
});
|
|
_vm.ManualEnrollmentCode = " ABC ";
|
|
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("ABC", _enrollment.LastQrContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_Refusee_AfficheMessage()
|
|
{
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Failed("Licence expirée");
|
|
_vm.ManualEnrollmentCode = "code";
|
|
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(_vm.IsEnrolled);
|
|
Assert.Equal("Licence expirée", _vm.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_ErreurReseau_AfficheMessageConnexion()
|
|
{
|
|
_enrollment.ThrowHttp = true;
|
|
_vm.ManualEnrollmentCode = "code";
|
|
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(_vm.IsEnrolled);
|
|
Assert.Contains("serveur", _vm.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enroll_IsEnrolling_ResetApresExecution()
|
|
{
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
|
|
{
|
|
OmikaApiUrl = "https://api.example.com"
|
|
});
|
|
_vm.ManualEnrollmentCode = "code";
|
|
|
|
await _vm.EnrollCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(_vm.IsEnrolling);
|
|
}
|
|
|
|
// ===== QR (automatique, sans toucher au champ manuel) =====
|
|
|
|
[Fact]
|
|
public async Task Scan_RetourneValeur_LanceInscriptionSansCopierDansLeChampManuel()
|
|
{
|
|
// Test 3 : Scan retourne "XYZ" → EnrollAsync reçoit "XYZ" et le champ
|
|
// manuel ne reçoit PAS "XYZ".
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
|
|
{
|
|
OmikaApiUrl = "https://api.example.com"
|
|
});
|
|
_scanner.ScanResult = "XYZ";
|
|
bool raised = false;
|
|
_vm.Enrolled += () => raised = true;
|
|
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("XYZ", _enrollment.LastQrContent);
|
|
Assert.Equal("XYZ", _vm.QrContent);
|
|
Assert.Equal(string.Empty, _vm.ManualEnrollmentCode);
|
|
Assert.True(_vm.IsEnrolled);
|
|
Assert.True(raised);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_ConserveLaSaisieManuelleExistante()
|
|
{
|
|
// Le QR ne doit pas écraser une saisie manuelle déjà présente.
|
|
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
|
|
{
|
|
OmikaApiUrl = "https://api.example.com"
|
|
});
|
|
_vm.ManualEnrollmentCode = "ABC";
|
|
_scanner.ScanResult = "XYZ";
|
|
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("XYZ", _enrollment.LastQrContent);
|
|
Assert.Equal("ABC", _vm.ManualEnrollmentCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_Annule_NeLancePasInscriptionEtChampManuelInchange()
|
|
{
|
|
// Test 4 : Scan retourne null → aucune inscription, champ manuel inchangé.
|
|
_scanner.ScanResult = null;
|
|
_vm.ManualEnrollmentCode = "ABC";
|
|
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("Scan annulé.", _vm.ErrorMessage);
|
|
Assert.Null(_enrollment.LastQrContent);
|
|
Assert.False(_vm.IsEnrolled);
|
|
Assert.Equal("ABC", _vm.ManualEnrollmentCode);
|
|
}
|
|
|
|
// ===== Journalisation des échecs de scan =====
|
|
|
|
/// <summary>
|
|
/// Défaillance technique du scanner (ouverture de la caméra en échec, cause
|
|
/// d'origine conservée) : l'utilisateur ne peut plus scanner, donc plus
|
|
/// s'inscrire — c'est un incident Error/P1, et il doit être VISIBLE dans le
|
|
/// Diagnostic Center (auparavant seule une ligne de journal fichier était écrite,
|
|
/// l'incident n'apparaissait nulle part dans le pipeline de diagnostic).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scan_DefaillanceCamera_SignaleEnIncident()
|
|
{
|
|
_scanner.ScanException = QrScanException.Defaillance(
|
|
"Ouverture de la caméra impossible : caméra déjà utilisée",
|
|
new InvalidOperationException("caméra déjà utilisée"));
|
|
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
try
|
|
{
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
}
|
|
finally
|
|
{
|
|
ExceptionTelemetry.Initialize(null, null);
|
|
}
|
|
|
|
Assert.False(_vm.IsScanning);
|
|
Assert.Equal("Ouverture de la caméra impossible : caméra déjà utilisée", _vm.ErrorMessage);
|
|
|
|
DiagnosticEvent evt = EvenementActivation(telemetry);
|
|
Assert.Equal(TelemetryLevel.Error, evt.Level);
|
|
Assert.Equal("QrScanException", evt.ErrorCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Repli plateforme (aucune implémentation caméra compilée) : l'application
|
|
/// demande simplement de coller le contenu du QR. État ATTENDU, pas un incident —
|
|
/// le journaliser en Error/P1 produirait un faux P1 à chaque appui sur « Scanner ».
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scan_ScanIndisponible_AucunIncident()
|
|
{
|
|
_scanner.ScanException = new QrScanException(
|
|
"Scan non disponible sur cet appareil : collez le contenu du QR dans le champ ci-dessous.");
|
|
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
try
|
|
{
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
}
|
|
finally
|
|
{
|
|
ExceptionTelemetry.Initialize(null, null);
|
|
}
|
|
|
|
Assert.False(_scanner.ScanException.EstDefaillance);
|
|
Assert.Contains("collez le contenu du QR", _vm.ErrorMessage);
|
|
Assert.Empty(telemetry.Events.OfType<DiagnosticEvent>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Permission caméra refusée par l'utilisateur : décision de l'utilisateur, pas
|
|
/// une défaillance de l'application — aucun incident.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scan_PermissionRefusee_AucunIncident()
|
|
{
|
|
_scanner.ScanException = new QrScanException(
|
|
"Permission caméra refusée. Autorisez l'accès à la caméra pour scanner le QR d'inscription.");
|
|
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
try
|
|
{
|
|
await _vm.ScanCommand.ExecuteAsync(null);
|
|
}
|
|
finally
|
|
{
|
|
ExceptionTelemetry.Initialize(null, null);
|
|
}
|
|
|
|
Assert.False(_scanner.ScanException.EstDefaillance);
|
|
Assert.Contains("Permission caméra refusée", _vm.ErrorMessage);
|
|
Assert.Empty(telemetry.Events.OfType<DiagnosticEvent>());
|
|
}
|
|
|
|
/// <summary>Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent).</summary>
|
|
private static DiagnosticEvent EvenementActivation(CapturingTelemetry telemetry) =>
|
|
Assert.Single(
|
|
telemetry.Events.OfType<DiagnosticEvent>(),
|
|
e => e.Component == nameof(LicenseActivationViewModel));
|
|
|
|
/// <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);
|
|
}
|
|
}
|