diff --git a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs index a3239a1..1f272e7 100644 --- a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs +++ b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs @@ -26,6 +26,38 @@ public sealed class DatabaseEncryptionServiceTests Assert.False(service.IsEncrypted(temp.Path)); } + /// Regression: SettingsViewModel ruft IsEncrypted/VerifyPassword mit dem Pfad der bereits + /// laufenden Datenbank auf, während LiteDbContext deren Shared-Verbindung noch offen hält + /// (z.B. bei jedem Öffnen der Einstellungen). Eine zweite Verbindung im Standard-Modus + /// (Direct) würde das mit "The process cannot access the file" quittieren. + [Fact] + public void IsEncrypted_WaehrendAndereSharedVerbindungOffenIst_WirftNicht() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(temp.Path)) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + using var open = new LiteDatabase(new ConnectionString(temp.Path) { Connection = ConnectionType.Shared }); + + var service = new DatabaseEncryptionService(); + + Assert.False(service.IsEncrypted(temp.Path)); + } + + [Fact] + public void VerifyPassword_WaehrendAndereSharedVerbindungOffenIst_WirftNicht() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(temp.Path)) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + var service = new DatabaseEncryptionService(); + service.SetPassword(temp.Path, currentPassword: null, newPassword: "geheim123"); + using var open = new LiteDatabase(new ConnectionString(temp.Path) + { Password = "geheim123", Connection = ConnectionType.Shared }); + + Assert.True(service.VerifyPassword(temp.Path, "geheim123")); + Assert.False(service.VerifyPassword(temp.Path, "falsch")); + } + [Fact] public void SetPassword_VerschluesseltDieDatenbankUndBehaeltDieDaten() { diff --git a/LehrerApp.Data/DatabaseEncryptionService.cs b/LehrerApp.Data/DatabaseEncryptionService.cs index b09bd31..b7a0608 100644 --- a/LehrerApp.Data/DatabaseEncryptionService.cs +++ b/LehrerApp.Data/DatabaseEncryptionService.cs @@ -10,12 +10,21 @@ namespace LehrerApp.Data; /// public class DatabaseEncryptionService { + // Beide Methoden werden auch gegen den Pfad der laufenden, bereits geöffneten Datenbank + // aufgerufen (z.B. bei jedem Öffnen der Einstellungen bzw. vor einer Passwort-Änderung) - + // LiteDbContext hält dafür längst eine ConnectionType.Shared-Verbindung offen. Ohne Shared + // hier ebenfalls zu setzen, würde die zweite Verbindung im Standard-Modus (Direct) einen + // exklusiven Dateizugriff verlangen und mit "The process cannot access the file" scheitern. public bool IsEncrypted(string dbPath) { if (!File.Exists(dbPath)) return false; try { - using var db = new LiteDatabase(dbPath); + // Shared-Verbindungen öffnen die Datei lazy - anders als Direct wirft der Konstruktor + // bei falschem/fehlendem Passwort noch nicht, erst ein tatsächlicher Zugriff liest den + // (ggf. verschlüsselten) Header. + using var db = new LiteDatabase(new ConnectionString(dbPath) { Connection = ConnectionType.Shared }); + _ = db.GetCollectionNames().ToList(); return false; } catch (LiteException) @@ -28,7 +37,9 @@ public class DatabaseEncryptionService { try { - using var db = new LiteDatabase(new ConnectionString(dbPath) { Password = password }); + using var db = new LiteDatabase(new ConnectionString(dbPath) + { Password = password, Connection = ConnectionType.Shared }); + _ = db.GetCollectionNames().ToList(); return true; } catch (LiteException) diff --git a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs index 2e98515..d45f91d 100644 --- a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using LehrerApp.Data; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Sync; using LehrerApp.Sync.Crypto; @@ -80,6 +81,39 @@ public sealed class SyncStatusViewModelTests Assert.Contains("Bitte aktualisiere die LehrerApp", SyncStatusViewModel.IncompatibleVersionMessage); } + /// Regression: ein Sync-Ausfall (z.B. Server nicht erreichbar) war früher nur an einem + /// blassen "Offline"-Text in der Statuszeile erkennbar - ein Gerät konnte so stundenlang + /// unbemerkt nicht synchronisieren. NotificationService bekommt jetzt aktiv eine Meldung, + /// sobald der Status neu auf Offline/Error wechselt. + [Fact] + public async Task SyncOffline_MeldetSichAktivUeberNotificationService() + { + using var temp = new TempSyncEngine(new OfflineStubHandler()); + var notifications = new NotificationService(); + var vm = new SyncStatusViewModel(temp.Engine, notifications); + + await temp.Engine.SyncNowAsync(); + + Assert.Equal("Offline", vm.StatusText); + Assert.Single(notifications.Toasts); + Assert.Equal(SyncStatusViewModel.OfflineMessage, notifications.Toasts[0].Message); + } + + /// Ein erneuter automatischer Retry im selben Offline-Zustand darf keinen weiteren Toast + /// auslösen - sonst würde der Nutzer alle paar Minuten dieselbe Meldung sehen. + [Fact] + public async Task SyncOffline_WiederholterRetry_MeldetNurEinmal() + { + using var temp = new TempSyncEngine(new OfflineStubHandler()); + var notifications = new NotificationService(); + var vm = new SyncStatusViewModel(temp.Engine, notifications); + + await temp.Engine.SyncNowAsync(); + await temp.Engine.SyncNowAsync(); + + Assert.Single(notifications.Toasts); + } + private sealed class PullEventStubHandler : HttpMessageHandler { protected override Task SendAsync( @@ -113,6 +147,13 @@ public sealed class SyncStatusViewModelTests }); } + private sealed class OfflineStubHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + } + private sealed class IncompatibleVersionStubHandler : HttpMessageHandler { protected override Task SendAsync( diff --git a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs index 42f3e3f..87ef22c 100644 --- a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs @@ -11,11 +11,16 @@ public partial class SyncStatusViewModel : ObservableObject private readonly SyncEngine? _engine; private readonly NotificationService? _notifications; private bool _incompatibleNotificationShown; + private SyncState _lastNotifiedState = SyncState.Idle; public const string IncompatibleVersionMessage = "Diese App-Version ist veraltet oder nicht mit dem Sync-Server kompatibel. " + "Bitte aktualisiere die LehrerApp. Die Synchronisierung wurde zum Schutz deiner Daten angehalten."; + public const string OfflineMessage = + "Synchronisierung nicht möglich - Änderungen werden lokal gespeichert und automatisch " + + "nachgeholt, sobald die Verbindung zum Server wieder da ist."; + [ObservableProperty] private string _statusText = "Kein Server konfiguriert"; [ObservableProperty] private string _lastSyncText = ""; [ObservableProperty] private bool _isSyncing; @@ -71,6 +76,27 @@ public partial class SyncStatusViewModel : ObservableObject _incompatibleNotificationShown = true; _notifications?.ShowError(IncompatibleVersionMessage); } + + // Offline/Error sonst nur als blasser Text in der Statuszeile sichtbar war - über Stunden + // unbemerkt, bis ein Blick auf ein zweites Gerät fehlende Daten offenbarte. Einmalig pro + // neuem Ausfall (nicht bei jedem automatischen Retry alle paar Minuten) aktiv als Toast + // melden; _lastNotifiedState wird bei Rückkehr zu Idle zurückgesetzt, damit ein späterer, + // neuer Ausfall wieder gemeldet wird. + if (s.State is SyncState.Offline or SyncState.Error && _lastNotifiedState != s.State) + { + _lastNotifiedState = s.State; + _notifications?.ShowError(s.State == SyncState.Offline + ? OfflineMessage + : $"Synchronisierung fehlgeschlagen: {s.ErrorMessage}"); + } + else if (s.State is SyncState.Idle) + { + // Nicht auch bei SyncState.Syncing zurücksetzen: Jeder Sync-Versuch (auch ein + // automatischer Retry im selben Ausfall) durchläuft kurz Syncing, bevor er wieder in + // Offline/Error landet - ein Reset hier würde bei jedem Retry erneut einen Toast + // auslösen, statt nur einmal pro tatsächlich neuem Ausfall. + _lastNotifiedState = SyncState.Idle; + } } [RelayCommand(CanExecute = nameof(CanSync))]