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>
383 lines
19 KiB
C#
383 lines
19 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Data;
|
|
using LehrerApp.Sync.Crypto;
|
|
using LehrerApp.Sync.Models;
|
|
using Xunit;
|
|
|
|
namespace LehrerApp.Sync.Tests;
|
|
|
|
public sealed class SyncEngineTests
|
|
{
|
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
|
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
|
|
|
/// Regression: EventApplier.ApplyAsync fing früher nur LiteException ab. Jede andere Ausnahme
|
|
/// (z.B. eine ungültige/korrupte Payload eines einzelnen Ereignisses) fiel unbehandelt aus
|
|
/// SyncEngine.PullAsync heraus, BEVOR _queue.SetLastServerSeq() erreicht wurde — der nächste
|
|
/// Sync-Versuch lud denselben Batch erneut und scheiterte am selben Ereignis erneut: ein
|
|
/// dauerhaft blockierter Sync, bei dem selbst bereits im selben Batch erfolgreich angewendete
|
|
/// Ereignisse (wie hier "goodStudent") nie durchkamen.
|
|
[Fact]
|
|
public async Task SyncNowAsync_KorruptesEreignisImPullBatch_BlockiertNachfolgendeEreignisseNicht()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
using var db = NewInMemoryContext();
|
|
var applier = new EventApplier(db, Key);
|
|
var resolver = new ConflictResolver(temp.Queue);
|
|
var attachments = new AttachmentSyncer(db, new HttpClient(), Key);
|
|
|
|
var goodStudent = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
|
var badEvent = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
|
EntityType = nameof(Student), EntityId = Guid.NewGuid().ToString(),
|
|
Operation = "Save", Payload = "offensichtlich-keine-gueltige-verschluesselte-payload",
|
|
SequenceNr = 1,
|
|
};
|
|
var goodEvent = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
|
EntityType = nameof(Student), EntityId = goodStudent.Id.ToString(),
|
|
Operation = "Save", Payload = SyncCrypto.EncryptObject(goodStudent, Key),
|
|
SequenceNr = 2,
|
|
};
|
|
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(new PullResponse
|
|
{ Events = [badEvent, goodEvent], ServerSequenceNr = 2 }),
|
|
};
|
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
|
});
|
|
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
|
var engine = new SyncEngine(temp.Queue, resolver, applier, attachments, http,
|
|
new SyncConfig { DeviceId = "this-device" });
|
|
|
|
var result = await engine.SyncNowAsync();
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(db.Students.FindById(goodStudent.Id));
|
|
// Nicht bei 0 stecken geblieben - der Batch gilt als vollständig verarbeitet, auch wenn ein
|
|
// einzelnes Ereignis darin übersprungen werden musste.
|
|
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
|
}
|
|
|
|
/// Regression: PushAsync setzte den lokalen Pull-Cursor bisher direkt aus PushResponse.
|
|
/// ServerSequenceNr - dem GLOBALEN Zähler über alle Geräte NACH diesem Push, nicht dem Stand,
|
|
/// den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der Server zum Push-Zeitpunkt
|
|
/// bereits ein noch nicht abgeholtes Ereignis eines ANDEREN Geräts mit niedrigerer ServerSeq,
|
|
/// sprang der Cursor beim eigenen Push darüber hinweg - der direkt anschließende PullAsync
|
|
/// fragte dann schon mit einem "since" danach und bekam 0 Ereignisse, obwohl das fremde
|
|
/// Ereignis nie angewendet wurde. Genau das vom Nutzer beobachtete Symptom ("Client 1 konnte
|
|
/// übermitteln, Client 2 bekommt weiterhin 'keine Änderungen'"), diesmal über einen anderen
|
|
/// Pfad als die bereits behobene EventStore.Pull-Wasserzeichen-Berechnung.
|
|
[Fact]
|
|
public async Task SyncNowAsync_EigenerPushWaehrendFremdesEreignisNochAussteht_LiefertFremdesEreignisTrotzdem()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
using var db = NewInMemoryContext();
|
|
var applier = new EventApplier(db, Key, versions: temp.Queue);
|
|
var goodStudent = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
|
var foreignEvent = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
|
EntityType = nameof(Student), EntityId = goodStudent.Id.ToString(),
|
|
Operation = "Save", Payload = SyncCrypto.EncryptObject(goodStudent, Key),
|
|
SequenceNr = 5,
|
|
};
|
|
temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", Guid.NewGuid().ToString(), "Save", "x");
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
// Globaler Höchststand (10) schließt das fremde, von DIESEM Gerät noch nicht
|
|
// abgeholte Ereignis (Seq 5) bereits mit ein - genau das durfte SyncEngine NICHT
|
|
// als eigenen Pull-Cursor übernehmen.
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 10 }) };
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
|
|
{
|
|
var query = req.RequestUri.Query.TrimStart('?').Split('&')
|
|
.Select(p => p.Split('=')).ToDictionary(p => p[0], p => p[1]);
|
|
var since = long.Parse(query["since"]);
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(since < 5
|
|
? new PullResponse { Events = [foreignEvent], ServerSequenceNr = 5 }
|
|
: new PullResponse { ServerSequenceNr = since }),
|
|
};
|
|
}
|
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
|
});
|
|
var engine = MakeEngine(temp, handler, applier);
|
|
|
|
var result = await engine.SyncNowAsync();
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(db.Students.FindById(goodStudent.Id));
|
|
}
|
|
|
|
// ── PushAsync: Dedup, BasedOnServerSeq, AssignedServerSeqs (TODO 10.3.4) ────────────────
|
|
|
|
[Fact]
|
|
public async Task PushAsync_MehrereAusstehendeEreignisseDerselbenEntitaet_SendetNurDasJuengste()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
var entityId = Guid.NewGuid().ToString();
|
|
temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "alt");
|
|
var newest = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "neu");
|
|
List<SyncEvent>? pushed = null;
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
{
|
|
pushed = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().GetAwaiter().GetResult();
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 1 }) };
|
|
}
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse()) };
|
|
});
|
|
var engine = MakeEngine(temp, handler);
|
|
|
|
var result = await engine.SyncNowAsync();
|
|
|
|
Assert.True(result.Success);
|
|
var evt = Assert.Single(pushed!);
|
|
Assert.Equal(newest.EventId, evt.EventId);
|
|
Assert.Equal("neu", evt.Payload);
|
|
Assert.Equal(0, temp.Queue.PendingCount());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
var entityId = Guid.NewGuid().ToString();
|
|
temp.Queue.SetKnownServerSeq("Lesson", entityId, 5);
|
|
temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "x");
|
|
List<SyncEvent>? pushed = null;
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
{
|
|
pushed = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().GetAwaiter().GetResult();
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 6 }) };
|
|
}
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse()) };
|
|
});
|
|
var engine = MakeEngine(temp, handler);
|
|
|
|
await engine.SyncNowAsync();
|
|
|
|
Assert.Equal(5, Assert.Single(pushed!).BasedOnServerSeq);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PushAsync_ErfolgreicherPush_AktualisiertLokaleVersionsverfolgung()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
var entityId = Guid.NewGuid().ToString();
|
|
var evt = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "x");
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(new PushResponse
|
|
{ ServerSequenceNr = 7, AssignedServerSeqs = new() { [evt.EventId] = 7 } }),
|
|
};
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse()) };
|
|
});
|
|
var engine = MakeEngine(temp, handler);
|
|
|
|
await engine.SyncNowAsync();
|
|
|
|
Assert.Equal(7, temp.Queue.GetKnownServerSeq("Lesson", entityId));
|
|
}
|
|
|
|
/// Regression: ein wegen neuerem Server-Stand abgelehnter Push blieb bisher unsichtbar - das
|
|
/// Ereignis verschwand einfach nicht aus der Queue, ohne dass der Nutzer je erfuhr, dass es
|
|
/// bereits neuere Daten gab (siehe TODO 10.3.4). RemoteWon-Fall: der Server-Stand gewinnt, wird
|
|
/// sofort angewendet, und die lokale Änderung wird verworfen.
|
|
[Fact]
|
|
public async Task PushAsync_AbgelehnterPushRemoteWon_WendetServerStandAnUndVerwirftLokal()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
using var db = NewInMemoryContext();
|
|
var entityId = Guid.NewGuid();
|
|
// Companion verliert immer gegen Desktop (siehe ConflictResolver.DetermineWinner).
|
|
var local = temp.Queue.Enqueue("companion-device", DeviceType.Companion,
|
|
nameof(Student), entityId.ToString(), "Save", "veraltete-lokale-payload");
|
|
var remoteStudent = new Student { Id = entityId, FirstName = "Anna", LastName = "Beispiel" };
|
|
var remoteEvent = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
|
EntityType = nameof(Student), EntityId = entityId.ToString(),
|
|
Operation = "Save", Payload = SyncCrypto.EncryptObject(remoteStudent, Key),
|
|
SequenceNr = 99,
|
|
};
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(new PushResponse
|
|
{ ServerSequenceNr = 99, ConflictingEventIds = [local.EventId] }),
|
|
};
|
|
if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/{nameof(Student)}/{entityId}")
|
|
return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(remoteEvent) };
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse()) };
|
|
});
|
|
var applier = new EventApplier(db, Key, versions: temp.Queue);
|
|
var engine = MakeEngine(temp, handler, applier);
|
|
|
|
await engine.SyncNowAsync();
|
|
|
|
var conflict = Assert.Single(temp.Queue.GetUnreviewed());
|
|
Assert.Equal("RemoteWon", conflict.Resolution);
|
|
Assert.NotNull(db.Students.FindById(entityId));
|
|
Assert.Equal(0, temp.Queue.PendingCount());
|
|
Assert.Equal(99, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString()));
|
|
}
|
|
|
|
/// Gegenstück: LocalWon-Fall - die lokale Änderung bleibt (unbestätigt) in der Queue, damit der
|
|
/// nächste Sync-Versuch sie mit dem soeben aktualisierten BasedOnServerSeq erneut versucht.
|
|
[Fact]
|
|
public async Task PushAsync_AbgelehnterPushLocalWon_BleibtUnbestaetigtInDerQueue()
|
|
{
|
|
using var temp = new TempEventQueue();
|
|
using var db = NewInMemoryContext();
|
|
var entityId = Guid.NewGuid();
|
|
// Desktop gewinnt immer gegen Companion (siehe ConflictResolver.DetermineWinner).
|
|
var local = temp.Queue.Enqueue("this-device", DeviceType.Desktop,
|
|
nameof(Student), entityId.ToString(), "Save", SyncCrypto.EncryptObject(
|
|
new Student { Id = entityId, FirstName = "Lokal", LastName = "Beispiel" }, Key));
|
|
var remoteEvent = new SyncEvent
|
|
{
|
|
DeviceId = "other-device", DeviceType = DeviceType.Companion,
|
|
EntityType = nameof(Student), EntityId = entityId.ToString(),
|
|
Operation = "Save", Payload = "irrelevant-verliert-ohnehin",
|
|
SequenceNr = 42,
|
|
};
|
|
var handler = new FakeHttpMessageHandler(req =>
|
|
{
|
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(new PushResponse
|
|
{ ServerSequenceNr = 42, ConflictingEventIds = [local.EventId] }),
|
|
};
|
|
if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/{nameof(Student)}/{entityId}")
|
|
return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(remoteEvent) };
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{ Content = JsonContent.Create(new PullResponse()) };
|
|
});
|
|
var applier = new EventApplier(db, Key, versions: temp.Queue);
|
|
var engine = MakeEngine(temp, handler, applier);
|
|
|
|
await engine.SyncNowAsync();
|
|
|
|
var conflict = Assert.Single(temp.Queue.GetUnreviewed());
|
|
Assert.Equal("LocalWon", conflict.Resolution);
|
|
Assert.Equal(1, temp.Queue.PendingCount());
|
|
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)
|
|
{
|
|
var db = NewInMemoryContext();
|
|
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
|
return new SyncEngine(temp.Queue, new ConflictResolver(temp.Queue),
|
|
applier ?? new EventApplier(db, Key), new AttachmentSyncer(db, http, Key), http,
|
|
new SyncConfig { DeviceId = "this-device" });
|
|
}
|
|
|
|
private sealed class TempEventQueue : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(
|
|
Path.GetTempPath(), $"lehrerapp-sync-tests-engine-{Guid.NewGuid():N}");
|
|
public EventQueue Queue { get; }
|
|
|
|
public TempEventQueue()
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Queue.Dispose();
|
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
}
|