diff --git a/LehrerApp.Data/AssemblyInfo.cs b/LehrerApp.Data/AssemblyInfo.cs new file mode 100644 index 0000000..09f26bf --- /dev/null +++ b/LehrerApp.Data/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext +// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*). +[assembly: InternalsVisibleTo("LehrerApp.Sync")] diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 94a4e77..28130ec 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -178,9 +178,15 @@ public static class AppBootstrapper if (!string.IsNullOrEmpty(serverUrl)) { + services.AddSingleton(sp => new EventApplier( + sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new SyncEventPublisher( + sp.GetRequiredService(), deviceId, sp.GetRequiredService())); + services.AddSingleton(sp => new SyncEngine( sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), BuildHttp(serverUrl, appData), new SyncConfig { @@ -223,7 +229,15 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); - return services.BuildServiceProvider(); + var provider = services.BuildServiceProvider(); + + // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, + // außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. + if (!string.IsNullOrEmpty(serverUrl)) + provider.GetRequiredService().OnChange = + provider.GetRequiredService().Publish; + + return provider; } // ── Hilfsmethoden ───────────────────────────────────────────────────────── diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs new file mode 100644 index 0000000..b8e6a0b --- /dev/null +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -0,0 +1,147 @@ +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, + }; +} diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs new file mode 100644 index 0000000..f1c687b --- /dev/null +++ b/LehrerApp.Sync/EventApplier.cs @@ -0,0 +1,112 @@ +using System.Text; +using JsonSerializer = System.Text.Json.JsonSerializer; +using LehrerApp.Core.Models; +using LehrerApp.Data; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; +using LiteDB; + +namespace LehrerApp.Sync; + +/// +/// Wendet ein von empfangenes (und nicht durch einen Konflikt +/// verlorenes) Ereignis auf die lokale Datenbank an. +/// +/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository- +/// Save/Delete-Methode — sonst würde erneut feuern und die +/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). +/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig +/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save +/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt. +/// +/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem +/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique- +/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses. +/// +public class EventApplier(LiteDbContext db, byte[] syncKey) +{ + private static readonly Dictionary Handlers = BuildHandlers(); + + public void Apply(SyncEvent evt) + { + if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return; + try + { + var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload); + handler(db, evt.Operation, evt.EntityId, json); + } + catch (LiteException) + { + // Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis + // überspringen, statt den gesamten Sync-Lauf abzubrechen. + } + } + + private string Decrypt(string payloadBase64) => + Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey)); + + private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json); + + private static Dictionary BuildHandlers() + { + var handlers = new Dictionary(); + + void Simple(Func> collection) where T : class => + handlers[typeof(T).Name] = (context, operation, entityId, json) => + { + if (operation == "Delete") collection(context).Delete(new Guid(entityId)); + else collection(context).Upsert(JsonSerializer.Deserialize(json)!); + }; + + Simple(context => context.Students); + Simple(context => context.SeatingPlans); + Simple(context => context.Memberships); + Simple(context => context.GradingKeyTemplates); + Simple(context => context.Grades); + Simple(context => context.GradingSchemes); + Simple(context => context.ReportGrades); + Simple(context => context.Units); + Simple(context => context.Lessons); + Simple(context => context.Tasks); + Simple(context => context.TimeEntries); + Simple(context => context.ExamResults); + Simple(context => context.ParticipationEntries); + Simple(context => context.ParticipationAspects); + Simple(context => context.ParticipationSections); + Simple(context => context.Subjects); + Simple(context => context.ShorthandCodes); + Simple(context => context.AlternativeLessonPaths); + Simple(context => context.TimetableSlots); + Simple(context => context.SchoolHolidays); + Simple(context => context.SupervisionDuties); + Simple(context => context.SubstitutionEntries); + Simple(context => context.CompetencyDomains); + + // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen + // Repositories, damit die Kaskade nur an einer Stelle im Code existiert. + handlers[nameof(LearningGroup)] = (context, operation, entityId, json) => + { + if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId)); + else context.Groups.Upsert(JsonSerializer.Deserialize(json)!); + }; + handlers[nameof(Exam)] = (context, operation, entityId, json) => + { + if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId)); + else context.Exams.Upsert(JsonSerializer.Deserialize(json)!); + }; + handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) => + { + if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId)); + else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize(json)!); + }; + // "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als + // "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig. + handlers[nameof(Documentation)] = (context, operation, entityId, json) => + { + if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId)); + else context.Documentation.Upsert(JsonSerializer.Deserialize(json)!); + }; + + return handlers; + } +} diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs index f3005de..812eaaa 100644 --- a/LehrerApp.Sync/SyncEngine.cs +++ b/LehrerApp.Sync/SyncEngine.cs @@ -11,6 +11,7 @@ public class SyncEngine : IDisposable { private readonly EventQueue _queue; private readonly ConflictResolver _resolver; + private readonly EventApplier _applier; private readonly HttpClient _http; private readonly SyncConfig _config; private readonly Timer _timer; @@ -18,11 +19,12 @@ public class SyncEngine : IDisposable public SyncStatus Status { get; private set; } = new(); public event Action? StatusChanged; - public SyncEngine(EventQueue queue, ConflictResolver resolver, + public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier, HttpClient http, SyncConfig config) { _queue = queue; _resolver = resolver; + _applier = applier; _http = http; _config = config; _timer = new Timer( @@ -75,7 +77,10 @@ public class SyncEngine : IDisposable foreach (var evt in resp.Events) { var c = _resolver.TryResolve(evt, _config.DeviceId); - if (c is not null) { _queue.AddConflict(c); conflicts++; } + if (c is null) { _applier.Apply(evt); continue; } + _queue.AddConflict(c); + conflicts++; + if (c.Resolution == "RemoteWon") _applier.Apply(evt); } _queue.SetLastServerSeq(resp.ServerSequenceNr); return (resp.Events.Count, conflicts); diff --git a/LehrerApp.Sync/SyncEventPublisher.cs b/LehrerApp.Sync/SyncEventPublisher.cs new file mode 100644 index 0000000..9c2dfe1 --- /dev/null +++ b/LehrerApp.Sync/SyncEventPublisher.cs @@ -0,0 +1,19 @@ +using LehrerApp.Data; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Wandelt -Aufrufe in ausgehende Sync-Ereignisse um. Wird in +/// AppBootstrapper an gehängt, wenn Sync konfiguriert ist — +/// dort entsteht aus den ~27 Repository-Aufrufen genau ein verschlüsseltes Ereignis pro Aufruf. +/// +public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKey) +{ + public void Publish(string entityType, string entityId, string operation, object? payload) + { + var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey); + queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted); + } +}