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); } } }