using LehrerApp.Sync.Models; using Xunit; namespace LehrerApp.Api.Tests; public sealed class EventStoreTests { private static SyncEvent MakeEvent(string deviceId, string entityType = "Lesson", string operation = "Save", string? entityId = null, long? basedOnServerSeq = null) => new() { DeviceId = deviceId, DeviceType = DeviceType.Desktop, EntityType = entityType, EntityId = entityId ?? Guid.NewGuid().ToString(), Operation = operation, Payload = "encrypted", BasedOnServerSeq = basedOnServerSeq, }; [Fact] public void Pull_KeineNeuenEreignisse_LiefertUnverändertesSinceAlsWatermark() { using var temp = new TempEventStore(); temp.Events.Push("user", [MakeEvent("device-a")]); var result = temp.Events.Pull("user", since: 5, requestingDeviceId: "device-b"); Assert.Empty(result.Events); Assert.Equal(5, result.ServerSequenceNr); } [Fact] public void Pull_LiefertEreignisseAnderesGeraets_WatermarkEntsprichtHoechsterZurueckgegebenerServerSeq() { using var temp = new TempEventStore(); temp.Events.Push("user", [MakeEvent("device-a"), MakeEvent("device-a")]); var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b"); Assert.Equal(2, result.Events.Count); Assert.Equal(2, result.ServerSequenceNr); } /// Regression: das genau beobachtete Symptom — Gerät A pusht erfolgreich (Server bestätigt), /// Gerät B pullt und bekommt 0 Ereignisse zurück, obwohl Gerät A's Ereignisse eigentlich für /// Gerät B bestimmt waren. Ursache: Pull() gab bisher IMMER den globalen Höchststand /// (LastSeq, inkl. der Ereignisse des ANFRAGENDEN Geräts selbst) als neuen "since"-Cursor /// zurück — SyncEngine.PullAsync übernimmt den 1:1. Hat das anfragende Gerät SELBST kurz vorher /// etwas gepusht (dessen Ereignisse hier bewusst per "DeviceId != requestingDeviceId" /// herausgefiltert werden), sprang der Cursor über die noch gar nicht abgeholten Ereignisse /// eines ANDEREN Geräts hinweg, wenn die eigenen neuer waren — sie wurden dauerhaft verpasst. [Fact] public void Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_UeberspringtFremdeEreignisseNicht() { using var temp = new TempEventStore(); // Gerät A pusht zuerst (ServerSeq 1) - das ist die Änderung, die Gerät B eigentlich abholen soll. temp.Events.Push("user", [MakeEvent("device-a")]); // Gerät B hat selbst (z.B. im selben Sync-Zyklus, Push läuft immer vor Pull) etwas Neueres // gepusht (ServerSeq 2) - dieses Ereignis gehört B selbst und wird unten aus der Pull-Antwort // herausgefiltert. temp.Events.Push("user", [MakeEvent("device-b")]); // Gerät B pullt mit einem "since" von VOR seinem eigenen Push (0) - Geräts A's Ereignis // (ServerSeq 1) muss zurückkommen. var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b"); var evt = Assert.Single(result.Events); Assert.Equal("device-a", evt.DeviceId); // Der Bug: hier stand vorher 2 (globaler Höchststand, inkl. Geräts B eigenem Ereignis) - // ein zweiter Pull mit since=2 hätte Geräts A's Ereignis (ServerSeq 1) nie wieder gefunden. Assert.Equal(1, result.ServerSequenceNr); } // ── Exakte Kollisionsprüfung (BasedOnServerSeq statt 30s-Heuristik) ───────── [Fact] public void Push_NeueEntitaetOhneBasedOnServerSeq_WirdAngenommen() { using var temp = new TempEventStore(); var result = temp.Events.Push("user", [MakeEvent("device-a", basedOnServerSeq: null)]); Assert.Empty(result.ConflictingEventIds); } [Fact] public void Push_ZweitesEreignisMitFalschemBasedOnServerSeq_WirdAbgelehnt() { using var temp = new TempEventStore(); var entityId = Guid.NewGuid().ToString(); temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); // -> ServerSeq 1 // Gerät B kennt die Entität noch gar nicht (oder einen veralteten Stand) und behauptet fälschlich, sie sei neu. var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null); var result = temp.Events.Push("user", [second]); Assert.Contains(second.EventId, result.ConflictingEventIds); } [Fact] public void Push_ZweitesEreignisMitKorrektemBasedOnServerSeq_WirdAngenommen() { using var temp = new TempEventStore(); var entityId = Guid.NewGuid().ToString(); var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); var assignedSeq = first.AssignedServerSeqs.Values.Single(); // ServerSeq 1 var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: assignedSeq); var result = temp.Events.Push("user", [second]); Assert.Empty(result.ConflictingEventIds); } [Fact] public void Push_AkzeptierteEreignisse_LiefernJeEventIdDieVergebeneServerSeq() { using var temp = new TempEventStore(); var evt1 = MakeEvent("device-a"); var evt2 = MakeEvent("device-a"); var result = temp.Events.Push("user", [evt1, evt2]); Assert.Equal(2, result.AssignedServerSeqs.Count); Assert.True(result.AssignedServerSeqs.ContainsKey(evt1.EventId)); Assert.True(result.AssignedServerSeqs.ContainsKey(evt2.EventId)); Assert.Equal([1L, 2L], result.AssignedServerSeqs.Values.OrderBy(v => v)); } [Fact] public void Push_AbgelehntesEreignis_TauchtNichtInAssignedServerSeqsAuf() { using var temp = new TempEventStore(); var entityId = Guid.NewGuid().ToString(); temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); var rejected = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null); var result = temp.Events.Push("user", [rejected]); Assert.False(result.AssignedServerSeqs.ContainsKey(rejected.EventId)); } // ── GetLatestForEntity (Sofort-Nachladen nach abgelehntem Push) ───────────── [Fact] public void GetLatestForEntity_UnbekannteEntitaet_LiefertNull() { using var temp = new TempEventStore(); var result = temp.Events.GetLatestForEntity("user", "Lesson", Guid.NewGuid().ToString()); Assert.Null(result); } [Fact] public void GetLatestForEntity_LiefertDenAktuellstenStandDieserEntitaet() { using var temp = new TempEventStore(); var entityId = Guid.NewGuid().ToString(); var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); var seq1 = first.AssignedServerSeqs.Values.Single(); temp.Events.Push("user", [MakeEvent("device-b", entityId: entityId, basedOnServerSeq: seq1)]); var result = temp.Events.GetLatestForEntity("user", "Lesson", entityId); Assert.NotNull(result); Assert.Equal("device-b", result!.DeviceId); Assert.Equal(seq1 + 1, result.SequenceNr); } private sealed class TempEventStore : IDisposable { private readonly string _directory = Path.Combine( Path.GetTempPath(), $"lehrerapp-api-tests-eventstore-{Guid.NewGuid():N}"); public EventStore Events { get; } public TempEventStore() { Directory.CreateDirectory(_directory); Events = new EventStore(_directory); } public void Dispose() { Events.Dispose(); if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } } }