fix: Pull-Wasserzeichen sprang über fremde Ereignisse + schärfere Push-Kollisionskontrolle
EventStore.Pull gab bisher den globalen ServerSeq-Höchststand als neuen Cursor zurück statt den höchsten unter den tatsächlich gelieferten Ereignissen - hatte ein Gerät selbst kurz zuvor etwas gepusht, sprang sein Pull-Cursor über noch nicht abgeholte Ereignisse anderer Geräte hinweg und verpasste sie dauerhaft, ohne jeden Fehler. Ersetzt außerdem die bisherige 30-Sekunden-Heuristik zur Konflikterkennung beim Push durch exakte BasedOnServerSeq-Prüfung: jedes SyncEvent trägt die ServerSeq, auf der es aufbaut: der Server lehnt ab, wenn der aktuelle Stand nicht mehr passt. Bei Ablehnung lädt der Client sofort den neuen Server-Stand nach, löst den Konflikt nach der bestehenden Desktop-vs-Companion/ Timestamp-Politik auf und macht ihn immer in der Konflikt-Review-UI sichtbar, statt die verworfene Änderung stillschweigend zu verlieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user