using LehrerApp.Core.Models; using LehrerApp.Data; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; using Xunit; namespace LehrerApp.Sync.Tests; public sealed class EventApplierTests { private static LiteDbContext NewInMemoryContext() => new(new MemoryStream()); private static readonly byte[] Key = SyncCrypto.GenerateKey(); [Fact] public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); var saved = db.Students.FindById(student.Id); Assert.NotNull(saved); Assert.Equal("Anna", saved!.FirstName); } [Fact] public async Task ApplyAsync_Delete_EntferntDenDatensatz() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; db.Students.Insert(student); await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Null(db.Students.FindById(student.Id)); } [Fact] public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var exception = await Record.ExceptionAsync(() => applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" }))); Assert.Null(exception); } [Fact] public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var groupId = Guid.NewGuid(); db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" }); var gradeId = Guid.NewGuid(); db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() }); await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Null(db.Groups.FindById(groupId)); Assert.Null(db.Grades.FindById(gradeId)); } [Fact] public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var studentId = Guid.NewGuid(); var groupId = Guid.NewGuid(); db.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId }); // Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index. var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId }; var exception = await Record.ExceptionAsync(() => applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate))); Assert.Null(exception); Assert.Single(db.Memberships.FindAll()); } // ── Loop-Prevention: der wichtigste Test in dieser Datei ──────────────────── // Ein angewendetes Ereignis darf NIE selbst wieder ein ausgehendes Ereignis auslösen, // sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar). [Fact] public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var onChangeCallCount = 0; db.OnChange = (_, _, _, _) => onChangeCallCount++; var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); Assert.Equal(0, onChangeCallCount); } [Fact] public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; db.Students.Insert(student); var onChangeCallCount = 0; db.OnChange = (_, _, _, _) => onChangeCallCount++; await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } [Fact] public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var groupId = Guid.NewGuid(); db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" }); db.Grades.Insert(new Grade { GroupId = groupId, StudentId = Guid.NewGuid() }); db.Exams.Insert(new Exam { GroupId = groupId }); var onChangeCallCount = 0; db.OnChange = (_, _, _, _) => onChangeCallCount++; await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } // ── Fehlende Anhänge nachladen ─────────────────────────────────────────────── [Fact] public async Task ApplyAsync_DocumentationMitFehlendemAnhang_LaedtIhnUeberHttpNach() { using var db = NewInMemoryContext(); var storageId = Guid.NewGuid().ToString("N"); var handler = new FakeHttpMessageHandler(req => { Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath); var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key); return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(encrypted) }; }); var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; var applier = new EventApplier(db, Key, http); var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" }; doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 }); await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc)); Assert.Single(handler.Requests); Assert.True(db.Attachments.Exists(storageId)); using var read = db.Attachments.OpenRead(storageId); using var ms = new MemoryStream(); await read.CopyToAsync(ms); Assert.Equal([9, 8, 7], ms.ToArray()); } [Fact] public async Task ApplyAsync_DocumentationMitBereitsVorhandenemAnhang_LaedtNichtErneut() { using var db = NewInMemoryContext(); var storageId = Guid.NewGuid().ToString("N"); db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3])); var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)); var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; var applier = new EventApplier(db, Key, http); var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" }; doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 }); await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc)); Assert.Empty(handler.Requests); } private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new() { DeviceId = "companion-1", DeviceType = DeviceType.Companion, EntityType = entityType, EntityId = entityId, Operation = operation, Payload = payload is null ? "" : SyncCrypto.EncryptObject(payload, Key), Timestamp = DateTime.UtcNow, }; }