fix: Sync blockierte dauerhaft und unsichtbar bei fehlerhaftem Ereignis
EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq) gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte sich höchstens als knapper Text in der Sync-Statusleiste. EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client, kein API-Redeploy nötig. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class SyncEngineTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||
|
||||
/// Regression: EventApplier.ApplyAsync fing früher nur LiteException ab. Jede andere Ausnahme
|
||||
/// (z.B. eine ungültige/korrupte Payload eines einzelnen Ereignisses) fiel unbehandelt aus
|
||||
/// SyncEngine.PullAsync heraus, BEVOR _queue.SetLastServerSeq() erreicht wurde — der nächste
|
||||
/// Sync-Versuch lud denselben Batch erneut und scheiterte am selben Ereignis erneut: ein
|
||||
/// dauerhaft blockierter Sync, bei dem selbst bereits im selben Batch erfolgreich angewendete
|
||||
/// Ereignisse (wie hier "goodStudent") nie durchkamen.
|
||||
[Fact]
|
||||
public async Task SyncNowAsync_KorruptesEreignisImPullBatch_BlockiertNachfolgendeEreignisseNicht()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var resolver = new ConflictResolver(temp.Queue);
|
||||
var attachments = new AttachmentSyncer(db, new HttpClient(), Key);
|
||||
|
||||
var goodStudent = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var badEvent = new SyncEvent
|
||||
{
|
||||
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
||||
EntityType = nameof(Student), EntityId = Guid.NewGuid().ToString(),
|
||||
Operation = "Save", Payload = "offensichtlich-keine-gueltige-verschluesselte-payload",
|
||||
SequenceNr = 1,
|
||||
};
|
||||
var goodEvent = new SyncEvent
|
||||
{
|
||||
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
||||
EntityType = nameof(Student), EntityId = goodStudent.Id.ToString(),
|
||||
Operation = "Save", Payload = SyncCrypto.EncryptObject(goodStudent, Key),
|
||||
SequenceNr = 2,
|
||||
};
|
||||
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new PullResponse
|
||||
{ Events = [badEvent, goodEvent], ServerSequenceNr = 2 }),
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var engine = new SyncEngine(temp.Queue, resolver, applier, attachments, http,
|
||||
new SyncConfig { DeviceId = "this-device" });
|
||||
|
||||
var result = await engine.SyncNowAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(db.Students.FindById(goodStudent.Id));
|
||||
// Nicht bei 0 stecken geblieben - der Batch gilt als vollständig verarbeitet, auch wenn ein
|
||||
// einzelnes Ereignis darin übersprungen werden musste.
|
||||
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
||||
}
|
||||
|
||||
private sealed class TempEventQueue : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"lehrerapp-sync-tests-engine-{Guid.NewGuid():N}");
|
||||
public EventQueue Queue { get; }
|
||||
|
||||
public TempEventQueue()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Queue.Dispose();
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user