using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.ViewModels; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// 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. /// [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 LoadAsync(CancellationToken ct = default) => Task.FromResult(null); public Task 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; } /// Échec du scanner à simuler (défaillance technique ou indisponibilité). public QrScanException? ScanException { get; set; } public Task ScanAsync(IQrScannerPreviewSink? previewSink, CancellationToken ct = default) => ScanException is { } ex ? Task.FromException(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 ===== /// /// 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). /// [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); } /// /// 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 ». /// [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()); } /// /// Permission caméra refusée par l'utilisateur : décision de l'utilisateur, pas /// une défaillance de l'application — aucun incident. /// [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()); } /// Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent). private static DiagnosticEvent EvenementActivation(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(LicenseActivationViewModel)); /// Capture des événements de la télémétrie statique globale. 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); } }