Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung

- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:52:44 +02:00
co-authored by Claude Sonnet 5
parent dbd8777e64
commit 2b299fc940
37 changed files with 2547 additions and 1823 deletions
+56
View File
@@ -92,6 +92,62 @@ public sealed class BackupServiceTests
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
}
[Fact]
public void CreateBackup_MitSecondaryDirectory_SpiegeltDasBackupDorthin()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
File.WriteAllText(dbPath, "Testinhalt");
var secondaryDir = Path.Combine(temp.Path, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
var backupPath = service.CreateBackup(dbPath);
Assert.NotNull(backupPath);
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backupPath!));
Assert.True(File.Exists(mirrored));
Assert.Equal("Testinhalt", File.ReadAllText(mirrored));
}
[Fact]
public void CreateBackup_SecondaryDirectoryNichtErreichbar_HauptbackupBleibtErhalten()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
File.WriteAllText(dbPath, "Testinhalt");
// Eine gewöhnliche Datei blockiert den Pfad, sodass darunter kein Verzeichnis angelegt
// werden kann — simuliert plattformunabhängig ein nicht eingestecktes USB-Ziel bzw. eine
// nicht erreichbare Netzwerkfreigabe.
var blockingFile = Path.Combine(temp.Path, "blockiert");
File.WriteAllText(blockingFile, "x");
var unreachable = Path.Combine(blockingFile, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = unreachable };
var backupPath = service.CreateBackup(dbPath);
Assert.NotNull(backupPath);
Assert.True(File.Exists(backupPath));
}
[Fact]
public void CreateBackup_MitSecondaryDirectory_BehaeltNurDieLetztenNAuchDort()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
var secondaryDir = Path.Combine(temp.Path, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
for (var i = 0; i < 5; i++)
{
File.WriteAllText(dbPath, $"v{i}");
var backup = service.CreateBackup(dbPath, keepCount: 3)!;
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backup));
File.SetLastWriteTime(mirrored, DateTime.Now.AddMinutes(-5 + i));
}
Assert.Equal(3, Directory.GetFiles(secondaryDir, "lehrerapp-*.db").Length);
}
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(