diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs index b0f4f6e..60a41f4 100644 --- a/LehrerApp.Data.Tests/ChangeHookTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookTests.cs @@ -57,4 +57,24 @@ public sealed class ChangeHookTests Assert.Null(exception); } + + // Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete + // (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform. + [Fact] + public void UntisHubJobStateRepository_Save_LoestOnChangeAus() + { + using var db = NewInMemoryContext(); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + var repo = new UntisHubJobStateRepository(db); + var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow }; + + repo.Save(state); + + var call = Assert.Single(calls); + Assert.Equal(nameof(UntisHubJobState), call.EntityType); + Assert.Equal(state.Id.ToString(), call.EntityId); + Assert.Equal("Save", call.Operation); + Assert.Same(state, call.Payload); + } } diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index a61ee29..304a6c2 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -907,7 +907,11 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep public List GetAll() => db.UntisHubJobStates.FindAll().ToList(); - public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state); + public void Save(UntisHubJobState state) + { + db.UntisHubJobStates.Upsert(state); + db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state); + } } public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository diff --git a/LehrerApp.Desktop/Services/UntisHubService.cs b/LehrerApp.Desktop/Services/UntisHubService.cs index c16fda5..1142960 100644 --- a/LehrerApp.Desktop/Services/UntisHubService.cs +++ b/LehrerApp.Desktop/Services/UntisHubService.cs @@ -72,8 +72,14 @@ public sealed class UntisHubService( public static List BuildRows( IReadOnlyList eligibleGroups, IReadOnlyList states, DateTime utcNow) { + // Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die + // denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen, + // bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe + // (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen. UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) => - states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId); + states.Where(s => s.Kind == kind && s.GroupId == groupId) + .OrderByDescending(s => s.LastRunAt) + .FirstOrDefault(); var rows = new List(); foreach (var group in eligibleGroups) diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index 6d6045a..118b9e8 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -38,6 +38,20 @@ public sealed class EventApplierTests Assert.Null(db.Students.FindById(student.Id)); } + [Fact] + public async Task ApplyAsync_UntisHubJobStateSave_SchreibtEntitaetDirektInDieCollection() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow }; + + await applier.ApplyAsync(MakeEvent(nameof(UntisHubJobState), state.Id.ToString(), "Save", state)); + + var saved = db.UntisHubJobStates.FindById(state.Id); + Assert.NotNull(saved); + Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind); + } + [Fact] public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() { diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 8b7cee5..f0a6b9a 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n Simple(context => context.SubstitutionEntries); Simple(context => context.CompetencyDomains); Simple(context => context.Vorgaenge); + Simple(context => context.UntisHubJobStates); // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Repositories, damit die Kaskade nur an einer Stelle im Code existiert. diff --git a/TODO.md b/TODO.md index b543c50..df4c25e 100644 --- a/TODO.md +++ b/TODO.md @@ -2097,6 +2097,26 @@ vollständig enthält. Zeilen), damit ein KI-Client für eine dort gelistete Gruppe nicht zusätzlich `get_groups` aufrufen muss, nur um den Fehlzeitenabgleich für sie auszulösen. + **Nachtrag (Nutzer-Feedback, 2026-09-13):** `UntisHubJobState` (die Fälligkeits-Zeitstempel + hinter dem Hub) lief bislang außerhalb des Sync — jedes Gerät führte seine eigene Buchhaltung. + In der Praxis zeigte das auf allen Instanzen dieselben offenen Punkte, was dem Zweck des Hubs + widerspricht (möglichst wenig WebUntis-Traffic: ein bereits auf Gerät A erledigter Abgleich soll + auf Gerät B nicht erneut als fällig erscheinen und zu einem zweiten, unnötigen Abruf verleiten). + Anders als die drei bewusst unsynchronisierten Report-Caches (`UntisAbsenceCacheRepository` & + Co., siehe oben) enthält `UntisHubJobState` keine WebUntis-Rohdaten, sondern nur Zeitstempel + + Kurztext — unkritisch für Sync. + - `UntisHubJobStateRepository.Save` ruft jetzt `db.OnChange` wie die übrigen ~28 synchronisierten + Repositories auf; `EventApplier` bekommt dafür einen zusätzlichen `Simple`- + Eintrag. Kein `Delete` nötig (Interface kennt keins) — Zeilen werden nur überschrieben. + - Geräte-Pairing (`SnapshotService`) war bereits unberührt, da es die komplette DB-Datei kopiert. + - Race-Härtung: `UntisHubService.RecordRun` legt bei einem (Kind, GroupId), das lokal noch nie + lief, eine neue `Id` an; laufen zwei Geräte offline denselben, noch nie ausgeführten Job + unabhängig voneinander, entstehen dadurch kurzzeitig zwei Datensätze für dasselbe Paar (kein + Unique-Index darauf). `UntisHubService.BuildRows` wählt deshalb jetzt den Datensatz mit dem + jüngsten `LastRunAt` statt eines beliebigen — der verwaiste zweite Datensatz bleibt harmlos in + der DB stehen (gleiches akzeptiertes v1-Verhalten wie bei anderen Entitäten ohne serverseitige + Merge-Logik, siehe TODO 10.3). + ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.