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>
148 lines
5.5 KiB
C#
148 lines
5.5 KiB
C#
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,
|
|
};
|
|
}
|