fix: Sync-Ausfälle aktiv melden, Einstellungen-Absturz durch Connection-Konflikt beheben
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Ein Sync-Ausfall (offline/Fehler) war bisher nur als blasser Text in der Statuszeile sichtbar und konnte so stundenlang unbemerkt bleiben, bis auf einem anderen Gerät Daten fehlten. SyncStatusViewModel meldet neue Ausfälle jetzt einmalig aktiv per Toast (nicht bei jedem automatischen Retry erneut). DatabaseEncryptionService.IsEncrypted/VerifyPassword öffneten die Datenbankdatei mit einer zweiten, exklusiven (Direct) LiteDB-Verbindung, während LiteDbContext dieselbe Datei bereits im Shared-Modus offen hält - jeder Aufruf (z.B. beim Öffnen der Einstellungen) crashte reproduzierbar mit IOException. Beide öffnen jetzt ebenfalls im Shared-Modus und erzwingen einen tatsächlichen Lesezugriff, da Shared-Verbindungen die Datei lazy öffnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
22eceb3ac1
commit
ecf64fff88
4 files changed
+112
-2
No files matched your search
@@ -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<BsonDocument>("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<BsonDocument>("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()
|
||||
{
|
||||
|
||||
@@ -10,12 +10,21 @@ namespace LehrerApp.Data;
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -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<HttpResponseMessage> SendAsync(
|
||||
@@ -113,6 +147,13 @@ public sealed class SyncStatusViewModelTests
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class OfflineStubHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
private sealed class IncompatibleVersionStubHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
|
||||
@@ -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))]
|
||||
|
||||
Reference in new issue
Block a user