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 void Apply_Save_SchreibtEntitaetDirektInDieCollection() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; applier.Apply(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 void Apply_Delete_EntferntDenDatensatz() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; db.Students.Insert(student); applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Null(db.Students.FindById(student.Id)); } [Fact] public void Apply_UnbekannterEntityType_TutNichtsUndWirftNicht() { using var db = NewInMemoryContext(); var applier = new EventApplier(db, Key); var exception = Record.Exception(() => applier.Apply(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" }))); Assert.Null(exception); } [Fact] public void Apply_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() }); applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Null(db.Groups.FindById(groupId)); Assert.Null(db.Grades.FindById(gradeId)); } [Fact] public void Apply_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 = Record.Exception(() => applier.Apply(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 void Apply_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" }; applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student)); Assert.Equal(0, onChangeCallCount); } [Fact] public void Apply_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++; applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } [Fact] public void Apply_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++; applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null)); Assert.Equal(0, onChangeCallCount); } 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, }; }