fix: Kontowechsel ließ Push dauerhaft mit 404 scheitern

Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId-
Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der
Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs
EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der
neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404
bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei
jedem weiteren Sync-Versuch.

Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der
nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der
"Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht
nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der
kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler
überhaupt auftreten kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 00:05:24 +02:00
co-authored by Claude Sonnet 5
parent ba8786906a
commit 979511ab6a
9 changed files with 229 additions and 13 deletions
+40
View File
@@ -77,6 +77,46 @@ public sealed class EventQueueTests
Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId));
}
[Fact]
public void ClearKnownServerSeq_EntferntNurDieAngegebeneEntitaet()
{
using var temp = new TempEventQueue();
var entityId = Guid.NewGuid().ToString();
temp.Queue.SetKnownServerSeq("Lesson", entityId, 42);
temp.Queue.SetKnownServerSeq("Unit", entityId, 7);
temp.Queue.ClearKnownServerSeq("Lesson", entityId);
Assert.Null(temp.Queue.GetKnownServerSeq("Lesson", entityId));
Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId));
}
[Fact]
public void ClearKnownServerSeq_UnbekannteEntitaet_TutNichtsUndWirftNicht()
{
using var temp = new TempEventQueue();
var exception = Record.Exception(() => temp.Queue.ClearKnownServerSeq("Lesson", "unbekannt"));
Assert.Null(exception);
}
/// Regression: nach einem Kontowechsel (TODO 10.3.5, z.B. Login-Korrektur der Groß-/
/// Kleinschreibung, siehe TODO 10.2.5) sind alle lokal zwischengespeicherten ServerSeq-Werte
/// bedeutungslos - sie beziehen sich auf das Event-Log eines ANDEREN Server-Kontos.
[Fact]
public void ResetKnownServerSeqs_EntferntAlleEintraege()
{
using var temp = new TempEventQueue();
temp.Queue.SetKnownServerSeq("Lesson", "a", 1);
temp.Queue.SetKnownServerSeq("Unit", "b", 2);
temp.Queue.ResetKnownServerSeqs();
Assert.Null(temp.Queue.GetKnownServerSeq("Lesson", "a"));
Assert.Null(temp.Queue.GetKnownServerSeq("Unit", "b"));
}
private static SyncEvent MakeEvent() => new()
{
DeviceId = "desktop-1",
+59
View File
@@ -292,6 +292,65 @@ public sealed class SyncEngineTests
Assert.Equal(42, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString()));
}
/// Regression (TODO 10.3.5): nach einem Kontowechsel (z.B. Login-Korrektur der Groß-/
/// Kleinschreibung, TODO 10.2.5) referenziert die lokale Versionsverfolgung ServerSeq-Werte
/// eines FREMDEN Kontos. Der Server lehnt den Push ab (BasedOnServerSeq-Mismatch), kennt die
/// Entität unter der aktuellen userId aber selbst gar nicht (404 bei GetLatestForEntity) - vor
/// diesem Fix blieb das Ereignis dadurch dauerhaft und ohne jede Selbstheilung stecken (derselbe
/// 404 bei jedem weiteren Sync-Versuch). Jetzt wird der stale Cache-Eintrag gelöscht, sodass
/// der NÄCHSTE Push die Entität korrekt als neu behandelt und vom (für sie leeren) Server-Konto
/// angenommen wird.
[Fact]
public async Task PushAsync_AbgelehnterPushServerKenntEntitaetNicht_LoeschtStaleCacheUndErholtSichSelbst()
{
using var temp = new TempEventQueue();
using var db = NewInMemoryContext();
var entityId = Guid.NewGuid().ToString();
// Stale Cache-Eintrag aus einem früheren (fremden) Konto - der Server unter der jetzigen
// userId hat davon nie etwas gehört.
temp.Queue.SetKnownServerSeq("Unit", entityId, 422);
var local = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Unit", entityId, "Save", "x");
var pushAttempts = 0;
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
{
pushAttempts++;
// Erster Versuch: BasedOnServerSeq=422 (stale) passt nicht zum leeren Server-Konto
// -> abgelehnt. Zweiter Versuch (nach Cache-Löschung): BasedOnServerSeq=null passt
// zur ebenfalls unbekannten Entität -> angenommen.
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(pushAttempts == 1
? new PushResponse { ServerSequenceNr = 0, ConflictingEventIds = [local.EventId] }
: new PushResponse
{
ServerSequenceNr = 1,
AssignedServerSeqs = new() { [local.EventId] = 1 },
}),
};
}
if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/Unit/{entityId}")
return new HttpResponseMessage(HttpStatusCode.NotFound);
return new HttpResponseMessage(HttpStatusCode.OK)
{ Content = JsonContent.Create(new PullResponse()) };
});
var engine = MakeEngine(temp, handler);
// SyncResult.Conflicts spiegelt nur PULL-Konflikte wider (siehe SyncEngine.SyncNowAsync) -
// Push-Konflikte werden ausschließlich geloggt, daher hier über EventsPushed==0 geprüft.
var first = await engine.SyncNowAsync();
Assert.Equal(0, first.EventsPushed);
Assert.Empty(temp.Queue.GetUnreviewed());
Assert.Null(temp.Queue.GetKnownServerSeq("Unit", entityId));
Assert.Equal(1, temp.Queue.PendingCount());
var second = await engine.SyncNowAsync();
Assert.Equal(1, second.EventsPushed);
Assert.Equal(0, temp.Queue.PendingCount());
}
private static SyncEngine MakeEngine(TempEventQueue temp, FakeHttpMessageHandler handler,
EventApplier? applier = null)
{