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>
197 lines
7.8 KiB
C#
197 lines
7.8 KiB
C#
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;
|
|
using LehrerApp.Sync.Models;
|
|
using Xunit;
|
|
|
|
namespace LehrerApp.Desktop.Tests;
|
|
|
|
public sealed class SyncStatusViewModelTests
|
|
{
|
|
[Fact]
|
|
public void Konstruktor_EngineBereitsVorhanden_ZeigtNichtDenNichtKonfiguriertText()
|
|
{
|
|
// SyncEngine feuert sein erstes StatusChanged schon im eigenen Konstruktor - läuft der vor
|
|
// dem Abonnieren in SyncStatusViewModel (z.B. weil beide DI-Singletons sind und
|
|
// GetService<SyncEngine>() den Engine-Konstruktor erst innerhalb der VM-Factory auslöst),
|
|
// ginge dieser erste Status verloren und StatusText bliebe fälschlich beim Default.
|
|
using var temp = new TempSyncEngine();
|
|
|
|
var vm = new SyncStatusViewModel(temp.Engine);
|
|
|
|
Assert.True(vm.IsServerConfigured);
|
|
Assert.NotEqual("Kein Server konfiguriert", vm.StatusText);
|
|
Assert.Equal("Synchronisiert", vm.StatusText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Konstruktor_KeineEngine_ZeigtNichtKonfiguriertText()
|
|
{
|
|
var vm = new SyncStatusViewModel(null);
|
|
|
|
Assert.False(vm.IsServerConfigured);
|
|
Assert.Equal("Kein Server konfiguriert", vm.StatusText);
|
|
}
|
|
|
|
/// Regression (TODO 10.1.11): eine per Sync-Pull eingehende Änderung erschien im UI erst nach
|
|
/// manuellem Neuladen (z.B. Tab-Wechsel), weil EventApplier absichtlich an jedem ViewModel
|
|
/// vorbei direkt auf die LiteDB schreibt. SyncStatusViewModel.DataChanged ist der Hook, über
|
|
/// den MainWindowViewModel die sichtbare Seite danach neu lädt - hier wird nur geprüft, dass
|
|
/// das Ereignis von SyncEngine bis hierher tatsächlich durchgereicht wird.
|
|
[Fact]
|
|
public async Task DataChanged_PullMitEreignissen_WirdVonEngineDurchgereicht()
|
|
{
|
|
using var temp = new TempSyncEngine(new PullEventStubHandler());
|
|
var vm = new SyncStatusViewModel(temp.Engine);
|
|
var fired = false;
|
|
vm.DataChanged += () => fired = true;
|
|
|
|
await temp.Engine.SyncNowAsync();
|
|
|
|
Assert.True(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataChanged_PullOhneEreignisse_FeuertNicht()
|
|
{
|
|
using var temp = new TempSyncEngine(new EmptyPullStubHandler());
|
|
var vm = new SyncStatusViewModel(temp.Engine);
|
|
var fired = false;
|
|
vm.DataChanged += () => fired = true;
|
|
|
|
await temp.Engine.SyncNowAsync();
|
|
|
|
Assert.False(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InkompatibleSyncVersion_ZeigtKlareUpdateMeldung()
|
|
{
|
|
using var temp = new TempSyncEngine(new IncompatibleVersionStubHandler());
|
|
var vm = new SyncStatusViewModel(temp.Engine);
|
|
|
|
await temp.Engine.SyncNowAsync();
|
|
|
|
Assert.True(vm.IsIncompatibleVersion);
|
|
Assert.Equal("Update erforderlich", vm.StatusText);
|
|
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(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
if (request.RequestUri!.AbsolutePath == "/api/sync/pull")
|
|
{
|
|
var evt = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
|
EntityType = "Student", EntityId = Guid.NewGuid().ToString(),
|
|
Operation = "Save", Payload = "irrelevant-fuer-diesen-test", SequenceNr = 1,
|
|
};
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse { Events = [evt], ServerSequenceNr = 1 }) });
|
|
}
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PushResponse()) });
|
|
}
|
|
}
|
|
|
|
private sealed class EmptyPullStubHandler : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken) =>
|
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(request.RequestUri!.AbsolutePath == "/api/sync/pull"
|
|
? new PullResponse()
|
|
: (object)new PushResponse()),
|
|
});
|
|
}
|
|
|
|
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(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var response = new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
|
|
response.Headers.Add(SyncProtocol.VersionHeaderName, "2");
|
|
return Task.FromResult(response);
|
|
}
|
|
}
|
|
|
|
private sealed class TempSyncEngine : IDisposable
|
|
{
|
|
private readonly string _queuePath = Path.Combine(
|
|
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
|
|
private readonly LiteDbContext _db = new(new MemoryStream());
|
|
private readonly HttpClient _http;
|
|
public SyncEngine Engine { get; }
|
|
|
|
public TempSyncEngine(HttpMessageHandler? handler = null)
|
|
{
|
|
_http = handler is null ? new HttpClient() : new HttpClient(handler);
|
|
_http.BaseAddress = new Uri("https://example.invalid");
|
|
var queue = new EventQueue(_queuePath);
|
|
var key = SyncCrypto.GenerateKey();
|
|
Engine = new SyncEngine(
|
|
queue, new ConflictResolver(queue), new EventApplier(_db, key),
|
|
new AttachmentSyncer(_db, _http, key), _http,
|
|
new SyncConfig { AutoSyncIntervalMinutes = 5 });
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Engine.Dispose(); // disposed auch die EventQueue
|
|
_db.Dispose();
|
|
_http.Dispose();
|
|
if (File.Exists(_queuePath)) File.Delete(_queuePath);
|
|
}
|
|
}
|
|
}
|