Files
LehrerApp/LehrerApp.Sync.Tests/ConflictResolverTests.cs
admin 273f459119 Testabdeckung Kapitel 13.1 (Tests, Fehlerbehandlung)
Vier neue xUnit-Testprojekte (LehrerApp.Tests, .Data.Tests, .Desktop.Tests,
.Sync.Tests) mit zusammen 104 Tests: GradingService, SchoolYearService,
Repositories gegen In-Memory-LiteDB, Mitarbeits-Aggregation (3.2),
Zeugnisnotenberechnung (2.4) und ConflictResolver.

Dabei zwei echte Fehler in der Sync-Schicht gefunden und behoben:
- SyncEvent.EventId fehlte [BsonId], wodurch EventQueue.Acknowledge()
  nie etwas aus der Queue löschte.
- ConflictResolver verglich Zeitstempel unterschiedlicher DateTimeKind
  direkt (LiteDB liefert Local statt Utc zurück), was die Gleichstand-
  Regel außerhalb von UTC+0 verfälschte.

SchoolYearService.CurrentSchoolYear()/RecentSchoolYears() um ein optionales
today-Argument erweitert, um den Schuljahreswechsel deterministisch zu
testen; LiteDbContext um einen Stream-Konstruktor für In-Memory-Tests.
2026-08-13 11:05:06 +02:00

178 lines
7.3 KiB
C#

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);
}
}
}