using LehrerApp.Sync.Models; using Xunit; namespace LehrerApp.Sync.Tests; public sealed class ConflictResolverTests { // ── Kein Konflikt ───────────────────────────────────────────────────────── [Fact] public void TryResolve_KeinLokalesEreignisFuerDieselbeEntitaet_GibtNullZurueck() { using var temp = new TempEventQueue(); var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1"); var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Null(result); } [Fact] public void TryResolve_LokalesEreignisFuerAndereEntitaet_ZaehltNichtAlsKonflikt() { using var temp = new TempEventQueue(); temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s2"); // andere EntityId var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Null(result); } [Fact] public void TryResolve_LokalesEreignisVomSelbenGeraet_ZaehltNichtAlsKonflikt() { using var temp = new TempEventQueue(); temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1"); // gleiche DeviceId var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Null(result); } // ── Desktop gewinnt immer gegen Companion ──────────────────────────────── [Fact] public void TryResolve_LokalDesktopGegenRemoteCompanion_DesktopGewinnt() { using var temp = new TempEventQueue(); var local = temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1"); var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.NotNull(result); Assert.Equal("LocalWon", result.Resolution); Assert.Equal(local.EventId, result.LocalEvent.EventId); } [Fact] public void TryResolve_LokalDesktopGegenRemoteCompanion_LokalesEreignisBleibtInDerQueue() { // Desktop gewinnt -> der lokale (gewinnende) Event wird NICHT bestätigt/entfernt, // da er ja noch zum Server gepusht werden muss. using var temp = new TempEventQueue(); temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1"); new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Equal(1, temp.Queue.PendingCount()); } [Fact] public void TryResolve_LokalCompanionGegenRemoteDesktop_DesktopGewinntTrotzAeltererZeit() { using var temp = new TempEventQueue(); var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}"); // Remote (Desktop) ist zeitlich "älter", gewinnt aber trotzdem, weil Desktop immer gewinnt. var remote = MakeEvent("desktop-1", DeviceType.Desktop, "Student", "s1", timestamp: local.Timestamp.AddMinutes(-5)); var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Equal("RemoteWon", result!.Resolution); } [Fact] public void TryResolve_RemoteGewinnt_LokalesEreignisWirdAusDerQueueEntfernt() { using var temp = new TempEventQueue(); temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}"); var remote = MakeEvent("desktop-1", DeviceType.Desktop, "Student", "s1"); new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Equal(0, temp.Queue.PendingCount()); } // ── Gleichstand (beide gleicher Gerätetyp): späterer Timestamp gewinnt ────── [Fact] public void TryResolve_BeideCompanion_SpaetererTimestampGewinnt() { using var temp = new TempEventQueue(); var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-2", DeviceType.Companion, "Student", "s1", timestamp: local.Timestamp.AddMinutes(5)); var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Equal("RemoteWon", result!.Resolution); } [Fact] public void TryResolve_BeideDesktopGleicherTimestamp_LokalesEreignisGewinntBeiGleichstand() { using var temp = new TempEventQueue(); temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}"); // Den tatsächlich aus der Queue geladenen (und damit BSON-gerundeten) Zeitstempel // verwenden, sonst wäre ein "exakter" Gleichstand durch Rundung beim Rundtrip nicht // exakt reproduzierbar. var reloadedTimestamp = temp.Queue.GetPending()[0].Timestamp.ToUniversalTime(); var remote = MakeEvent("desktop-2", DeviceType.Desktop, "Student", "s1", timestamp: reloadedTimestamp); var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); // ">= " in der Ungleichung: bei exaktem Gleichstand gewinnt das lokale Ereignis. Assert.Equal("LocalWon", result!.Resolution); } [Fact] public void TryResolve_BeideCompanionAelteresLokalesEreignis_RemoteGewinntAlsNeuer() { using var temp = new TempEventQueue(); var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}"); var remote = MakeEvent("companion-2", DeviceType.Companion, "Student", "s1", timestamp: local.Timestamp.AddSeconds(-1)); // remote ist älter -> lokal (neuer) gewinnt var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1"); Assert.Equal("LocalWon", result!.Resolution); } // ── Hilfsfunktionen ─────────────────────────────────────────────────────── private static SyncEvent MakeEvent(string deviceId, DeviceType deviceType, string entityType, string entityId, DateTime? timestamp = null) => new() { DeviceId = deviceId, DeviceType = deviceType, EntityType = entityType, EntityId = entityId, Operation = "Update", Payload = "{}", Timestamp = timestamp ?? DateTime.UtcNow, }; private sealed class TempEventQueue : IDisposable { private readonly string _directory = Path.Combine( Path.GetTempPath(), $"lehrerapp-sync-tests-{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); } } }