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:
@@ -59,6 +59,28 @@ public sealed class SyncSettingsServiceTests
|
|||||||
Assert.DoesNotContain("geheimes-token-123", raw);
|
Assert.DoesNotContain("geheimes-token-123", raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCredentialsAndToken_MitUserId_PersistiertLastUserIdUeberNeueInstanz()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
new SyncSettingsService(path).SetCredentialsAndToken("Sebastian", "token-abc", "sebastian");
|
||||||
|
|
||||||
|
var reloaded = new SyncSettingsService(path);
|
||||||
|
|
||||||
|
Assert.Equal("sebastian", reloaded.LastUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCredentialsAndToken_OhneUserId_LaesstVorhandeneLastUserIdUnveraendert()
|
||||||
|
{
|
||||||
|
var service = new SyncSettingsService(BuildTempPath());
|
||||||
|
service.SetCredentialsAndToken("sebastian", "token-1", "sebastian");
|
||||||
|
|
||||||
|
service.SetCredentialsAndToken("sebastian", "token-2");
|
||||||
|
|
||||||
|
Assert.Equal("sebastian", service.LastUserId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
|
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SyncAuthService(HttpClient http)
|
public class SyncAuthService(HttpClient http)
|
||||||
{
|
{
|
||||||
public async Task<string> LoginAsync(string serverUrl, string username, string password)
|
/// <summary>
|
||||||
|
/// Liefert neben dem Token auch die kanonische userId aus der Server-Antwort - LiteDBs
|
||||||
|
/// case-insensitive Standard-Collation lässt einen Login mit abweichender
|
||||||
|
/// Groß-/Kleinschreibung des Benutzernamens erfolgreich durch (siehe TODO 10.2.5); die
|
||||||
|
/// zurückgegebene userId ist deshalb der einzige verlässliche Weg für den Aufrufer, einen
|
||||||
|
/// Kontowechsel von einer bloßen Schreibweisen-Abweichung zu unterscheiden (siehe TODO 10.3.5).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(string Token, string UserId)> LoginAsync(string serverUrl, string username, string password)
|
||||||
{
|
{
|
||||||
HttpResponseMessage resp;
|
HttpResponseMessage resp;
|
||||||
try
|
try
|
||||||
@@ -34,7 +41,9 @@ public class SyncAuthService(HttpClient http)
|
|||||||
throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
|
throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
|
||||||
|
|
||||||
var result = await resp.Content.ReadFromJsonAsync<LoginResult>();
|
var result = await resp.Content.ReadFromJsonAsync<LoginResult>();
|
||||||
return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.");
|
return result is null
|
||||||
|
? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.")
|
||||||
|
: (result.Token, result.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ internal class SyncSettingsConfig
|
|||||||
public string ServerUrl { get; set; } = "";
|
public string ServerUrl { get; set; } = "";
|
||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public string? EncryptedToken { get; set; }
|
public string? EncryptedToken { get; set; }
|
||||||
|
/// <summary>Kanonische userId aus der letzten erfolgreichen Login-Antwort (siehe
|
||||||
|
/// SettingsViewModel.SyncLogin) — dient dem Erkennen eines Kontowechsels, unabhängig von der
|
||||||
|
/// beim Login eingegebenen Groß-/Kleinschreibung des Benutzernamens (siehe TODO 10.3.5).</summary>
|
||||||
|
public string? LastUserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -27,6 +31,7 @@ public class SyncSettingsService
|
|||||||
|
|
||||||
public string ServerUrl => _config.ServerUrl;
|
public string ServerUrl => _config.ServerUrl;
|
||||||
public string Username => _config.Username;
|
public string Username => _config.Username;
|
||||||
|
public string? LastUserId => _config.LastUserId;
|
||||||
public bool IsLoggedIn => _config.EncryptedToken is not null;
|
public bool IsLoggedIn => _config.EncryptedToken is not null;
|
||||||
|
|
||||||
public SyncSettingsService(string appDataPath)
|
public SyncSettingsService(string appDataPath)
|
||||||
@@ -43,10 +48,11 @@ public class SyncSettingsService
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetCredentialsAndToken(string username, string token)
|
public void SetCredentialsAndToken(string username, string token, string? userId = null)
|
||||||
{
|
{
|
||||||
_config.Username = username;
|
_config.Username = username;
|
||||||
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
|
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
|
||||||
|
if (userId is not null) _config.LastUserId = userId;
|
||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -477,9 +477,22 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var token = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
||||||
|
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
|
||||||
|
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
|
||||||
|
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
|
||||||
|
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
|
||||||
|
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
|
||||||
|
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
|
||||||
|
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
|
||||||
|
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
|
||||||
_syncSettings.SetServerUrl(SyncServerUrl);
|
_syncSettings.SetServerUrl(SyncServerUrl);
|
||||||
_syncSettings.SetCredentialsAndToken(SyncUsername, token);
|
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
|
||||||
|
if (accountChanged)
|
||||||
|
{
|
||||||
|
_eventQueue.SetLastServerSeq(0);
|
||||||
|
_eventQueue.ResetKnownServerSeqs();
|
||||||
|
}
|
||||||
SyncPassword = "";
|
SyncPassword = "";
|
||||||
AppBootstrapper.RestartApplication();
|
AppBootstrapper.RestartApplication();
|
||||||
}
|
}
|
||||||
@@ -494,11 +507,13 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Setzt den lokalen Sync-Fortschritt zurück und lädt danach alle Ereignisse dieses Nutzers
|
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
|
||||||
/// erneut vom Server - manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen
|
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
|
||||||
/// eines anderen Geräts erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor,
|
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
|
||||||
/// siehe TODO 10.1.10). Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/
|
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
|
||||||
/// Delete-by-Id idempotent an.
|
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
|
||||||
|
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
|
||||||
|
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SyncForceFullResync()
|
private async Task SyncForceFullResync()
|
||||||
@@ -509,6 +524,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_eventQueue.SetLastServerSeq(0);
|
_eventQueue.SetLastServerSeq(0);
|
||||||
|
_eventQueue.ResetKnownServerSeqs();
|
||||||
var result = await _syncEngine.SyncNowAsync();
|
var result = await _syncEngine.SyncNowAsync();
|
||||||
SyncForceResyncStatus = result.Success
|
SyncForceResyncStatus = result.Success
|
||||||
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
||||||
|
|||||||
@@ -77,6 +77,46 @@ public sealed class EventQueueTests
|
|||||||
Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId));
|
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()
|
private static SyncEvent MakeEvent() => new()
|
||||||
{
|
{
|
||||||
DeviceId = "desktop-1",
|
DeviceId = "desktop-1",
|
||||||
|
|||||||
@@ -292,6 +292,65 @@ public sealed class SyncEngineTests
|
|||||||
Assert.Equal(42, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString()));
|
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,
|
private static SyncEngine MakeEngine(TempEventQueue temp, FakeHttpMessageHandler handler,
|
||||||
EventApplier? applier = null)
|
EventApplier? applier = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ public class EventQueue : IDisposable
|
|||||||
_entityVersions.Upsert(new EntityVersion
|
_entityVersions.Upsert(new EntityVersion
|
||||||
{ Key = EntityVersionKey(entityType, entityId), ServerSeq = serverSeq });
|
{ Key = EntityVersionKey(entityType, entityId), ServerSeq = serverSeq });
|
||||||
|
|
||||||
|
/// <summary>Löscht den bekannten Stand EINER Entität — z.B. wenn der Server auf eine
|
||||||
|
/// BasedOnServerSeq-Ablehnung hin meldet, die Entität gar nicht zu kennen (404 bei
|
||||||
|
/// GetLatestForEntity): der lokale Cache war dann stale, siehe SyncEngine.HandleRejectedAsync
|
||||||
|
/// und TODO 10.3.5.</summary>
|
||||||
|
public void ClearKnownServerSeq(string entityType, string entityId) =>
|
||||||
|
_entityVersions.Delete(EntityVersionKey(entityType, entityId));
|
||||||
|
|
||||||
|
/// <summary>Verwirft die GESAMTE lokale Versionsverfolgung — nötig nach einem Kontowechsel
|
||||||
|
/// (siehe SettingsViewModel.SyncLogin/SyncForceFullResync), da ServerSeq-Werte ausschließlich
|
||||||
|
/// innerhalb des Event-Logs EINES Server-Kontos bedeutungsvoll sind (TODO 10.3.5). Sicher: der
|
||||||
|
/// nächste Push behandelt jede Entität dann als "erstmals für dieses Konto", der Server nimmt
|
||||||
|
/// sie an, solange er sie unter der aktuellen userId selbst noch nicht kennt.</summary>
|
||||||
|
public void ResetKnownServerSeqs() => _entityVersions.DeleteAll();
|
||||||
|
|
||||||
private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}";
|
private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}";
|
||||||
|
|
||||||
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
@@ -150,11 +151,10 @@ public class SyncEngine : IDisposable
|
|||||||
{
|
{
|
||||||
foreach (var local in rejected)
|
foreach (var local in rejected)
|
||||||
{
|
{
|
||||||
SyncEvent? remote;
|
HttpResponseMessage resp;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
remote = await _http.GetFromJsonAsync<SyncEvent>(
|
resp = await _http.GetAsync($"/api/sync/entity/{local.EntityType}/{local.EntityId}");
|
||||||
$"/api/sync/entity/{local.EntityType}/{local.EntityId}");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -162,6 +162,21 @@ public class SyncEngine : IDisposable
|
|||||||
"aktueller Server-Stand konnte nicht nachgeladen werden.", ex);
|
"aktueller Server-Stand konnte nicht nachgeladen werden.", ex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
// Der Server kennt diese Entität unter der aktuellen userId gar nicht - der lokal
|
||||||
|
// zwischengespeicherte BasedOnServerSeq war stale (z.B. nach einem Kontowechsel,
|
||||||
|
// siehe TODO 10.3.5, oder wenn dieses Gerät die Entität nie zuvor unter diesem
|
||||||
|
// Konto gepusht hat). Cache löschen, statt endlos mit demselben 404 zu scheitern -
|
||||||
|
// der nächste Push behandelt die Entität dann korrekt als neu für dieses Konto.
|
||||||
|
_queue.ClearKnownServerSeq(local.EntityType, local.EntityId);
|
||||||
|
_logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId} - Server " +
|
||||||
|
"kennt diese Entität nicht. Lokale Versionsverfolgung zurückgesetzt, " +
|
||||||
|
"nächster Push behandelt sie als neu.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var remote = await resp.Content.ReadFromJsonAsync<SyncEvent>();
|
||||||
if (remote is null)
|
if (remote is null)
|
||||||
{
|
{
|
||||||
_logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId}, aber " +
|
_logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId}, aber " +
|
||||||
|
|||||||
@@ -1772,6 +1772,18 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
|
|||||||
"abgehängten" Gerät einmal "Vollständigen Sync erzwingen" (10.1.10) nutzen, damit es den
|
"abgehängten" Gerät einmal "Vollständigen Sync erzwingen" (10.1.10) nutzen, damit es den
|
||||||
unter der korrekten `userId` bereits vorhandenen Bestand nachlädt.
|
unter der korrekten `userId` bereits vorhandenen Bestand nachlädt.
|
||||||
|
|
||||||
|
**Nachtrag (Folgefehler nach dem Re-Login — 10.3.5):** Nach der Korrektur landete ein
|
||||||
|
Gerät in einer Endlosschleife: jeder Push desselben `Unit`-Ereignisses wurde mit einem 404
|
||||||
|
abgelehnt ("aktueller Server-Stand konnte nicht nachgeladen werden"). Ursache: die lokale
|
||||||
|
Versionsverfolgung je Entität (`EventQueue.entity_versions`, `BasedOnServerSeq`) UND der
|
||||||
|
Pull-Cursor (`GetLastServerSeq`) bezogen sich noch auf das ALTE (falsch geschriebene) Konto
|
||||||
|
— ServerSeq-Werte sind aber ausschließlich innerhalb des Event-Logs EINES Kontos
|
||||||
|
bedeutungsvoll. Der Push wurde deshalb zu Recht abgelehnt (`BasedOnServerSeq` passte nicht
|
||||||
|
zum neuen, leeren Konto), aber `GetLatestForEntity` kannte die Entität unter der neuen
|
||||||
|
`userId` gar nicht (404) — und `HandleRejectedAsync` gab bei einem 404 bisher einfach auf,
|
||||||
|
ohne den stale Cache-Eintrag zu bereinigen: derselbe Fehlschlag bei jedem weiteren Versuch.
|
||||||
|
Siehe 10.3.5 unten für den Fix.
|
||||||
|
|
||||||
### 10.3 Verschlüsselung
|
### 10.3 Verschlüsselung
|
||||||
- [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
|
- [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
|
||||||
|
|
||||||
@@ -1844,6 +1856,29 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
|
|||||||
akzeptiert (Einzel-/Wenig-Geräte-Nutzung) — falls das je zum echten Problem wird, müsste der
|
akzeptiert (Einzel-/Wenig-Geräte-Nutzung) — falls das je zum echten Problem wird, müsste der
|
||||||
Event-Applier dieselben Validierungen wie die Repository-Save-Methoden durchlaufen, ohne
|
Event-Applier dieselben Validierungen wie die Repository-Save-Methoden durchlaufen, ohne
|
||||||
dabei erneut ein Sync-Ereignis auszulösen.
|
dabei erneut ein Sync-Ereignis auszulösen.
|
||||||
|
- [x] **10.3.5** Stale lokale Versionsverfolgung nach einem Kontowechsel (direkte Folge von
|
||||||
|
10.3.4 + 10.2.5, siehe Nachtrag zu 10.2.5).
|
||||||
|
|
||||||
|
**Fix, dreiteilig:**
|
||||||
|
1. `SyncEngine.HandleRejectedAsync`: ein 404 von `GET /api/sync/entity/{type}/{id}`
|
||||||
|
bedeutet, der Server kennt die Entität unter der aktuellen `userId` gar nicht — statt
|
||||||
|
endlos mit demselben Fehler zu scheitern, wird jetzt `EventQueue.ClearKnownServerSeq`
|
||||||
|
für genau diese Entität aufgerufen; der nächste Push behandelt sie korrekt als neu und
|
||||||
|
wird vom (für sie leeren) Konto angenommen. Selbstheilend, ohne Nutzeraktion nötig.
|
||||||
|
2. `EventQueue.ResetKnownServerSeqs()` (löscht die gesamte `entity_versions`-Collection) im
|
||||||
|
"Vollständigen Sync erzwingen"-Button (10.1.10) ergänzt — der Button setzt jetzt sowohl
|
||||||
|
den Pull-Cursor als auch die Push-Versionsverfolgung zurück.
|
||||||
|
3. `SyncLogin` erkennt einen Kontowechsel jetzt proaktiv: `SyncAuthService.LoginAsync`
|
||||||
|
liefert neben dem Token auch die KANONISCHE `userId` aus der Server-Antwort zurück (nicht
|
||||||
|
den beim Login eingegebenen Namen). Weicht sie von der zuletzt gespeicherten
|
||||||
|
(`SyncSettingsService.LastUserId`) ab, werden Pull-Cursor und Versionsverfolgung
|
||||||
|
automatisch zurückgesetzt — zukünftige Kontowechsel (oder Schreibweisen-Korrekturen wie
|
||||||
|
hier) lösen den Folgefehler dadurch gar nicht erst aus.
|
||||||
|
Neue Tests: `EventQueueTests` (`ClearKnownServerSeq`, `ResetKnownServerSeqs`),
|
||||||
|
`SyncEngineTests.PushAsync_AbgelehnterPushServerKenntEntitaetNicht_
|
||||||
|
LoeschtStaleCacheUndErholtSichSelbst` (Ende-zu-Ende: abgelehnter Push mit stale Cache →
|
||||||
|
Selbstheilung → zweiter Push erfolgreich), `SyncSettingsServiceTests` (`LastUserId`
|
||||||
|
persistiert/bleibt bei fehlendem `userId`-Argument unverändert).
|
||||||
|
|
||||||
**Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests,
|
**Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests,
|
||||||
inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,
|
inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,
|
||||||
|
|||||||
Reference in New Issue
Block a user