Baustein 5: EventApplier (Inbound Apply) + Loop-Prevention (Kapitel 10)
Die zentrale, bisher komplett fehlende Luecke: selbst mit Baustein 1-4 haette SyncEngine.PullAsync empfangene Ereignisse nur zur Konflikterkennung genutzt, nie in die lokale LiteDB geschrieben - ankommende Aenderungen von anderen Geraeten waeren nirgends sichtbar geworden. Neu EventApplier: entschluesselt, dispatcht ueber eine explizite EntityType-Tabelle, schreibt IMMER direkt auf die rohe LiteDB- Collection, nie ueber eine Repository-Save/Delete-Methode - sonst wuerde der OnChange-Hook (Baustein 2) die gerade angewendete Aenderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). Ein gemeinsames Suppress-Flag wurde geprueft und verworfen: SyncEngine laeuft per Timer nebenlaeufig zum UI-Thread, ein Flag koennte einen echten Nutzer-Save waehrenddessen verschlucken. Der direkte Collection- Zugriff ist zustandslos und dadurch korrekt. Kaskaden-Faelle nutzen dieselben internen LiteDbContext-Hilfsmethoden wie die Repositories (Baustein 4). Neu SyncEventPublisher, der den OnChange-Hook in ein verschluesseltes EventQueue.Enqueue uebersetzt (an LiteDbContext.OnChange gehaengt). Mit dediziertem Loop-Prevention-Test abgesichert: belegt mit echtem LiteDbContext + OnChange-Zaehler, dass Apply keinen neuen Hook-Aufruf ausloest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user