Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9286bfa3b5 | ||
|
|
6774123270 | ||
|
|
fc2d7aea3e | ||
|
|
de98b4ed54 | ||
|
|
95345f9c46 | ||
|
|
f0f8fa25e5 | ||
|
|
ce4dfb0197 | ||
|
|
1240a2cd8a | ||
|
|
ed34e5d036 | ||
|
|
831cc1c16c | ||
|
|
6f9de325d5 |
@@ -0,0 +1,70 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class AttachmentStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
|
||||
|
||||
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
|
||||
|
||||
using var read = store.OpenRead("user-1", "abc123");
|
||||
Assert.NotNull(read);
|
||||
using var ms = new MemoryStream();
|
||||
await read!.CopyToAsync(ms);
|
||||
Assert.Equal(original, ms.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
Assert.Null(store.OpenRead("user-1", "unbekannt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||
|
||||
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
|
||||
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
|
||||
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
|
||||
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
|
||||
}
|
||||
|
||||
private sealed class TempDataPath : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
|
||||
|
||||
public TempDataPath() => Directory.CreateDirectory(Path);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Api\LehrerApp.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,41 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class PasswordHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Verify_RichtigesPasswort_GibtTrueZurueck()
|
||||
{
|
||||
var hash = PasswordHasher.Hash("korrektes-passwort-123");
|
||||
|
||||
Assert.True(PasswordHasher.Verify("korrektes-passwort-123", hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
var hash = PasswordHasher.Hash("korrektes-passwort-123");
|
||||
|
||||
Assert.False(PasswordHasher.Verify("falsches-passwort-456", hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ZweiAufrufeMitGleichemPasswort_ErzeugenUnterschiedlicheHashes()
|
||||
{
|
||||
// Zufälliger Salt pro Nutzer -> selbst identische Passwörter dürfen nicht denselben
|
||||
// gespeicherten Wert ergeben.
|
||||
var hash1 = PasswordHasher.Hash("dasselbe-passwort");
|
||||
var hash2 = PasswordHasher.Hash("dasselbe-passwort");
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash1));
|
||||
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_UngueltigesGespeichertesFormat_GibtFalseZurueckStattZuWerfen()
|
||||
{
|
||||
Assert.False(PasswordHasher.Verify("irgendwas", "kein-gueltiges-format"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class UserStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateUser_NeuerNutzername_GibtTrueZurueckUndKannSichAnmelden()
|
||||
{
|
||||
using var temp = new TempUserStore();
|
||||
|
||||
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||
|
||||
Assert.True(created);
|
||||
Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_BereitsVorhandenerNutzername_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempUserStore();
|
||||
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||
|
||||
var created = temp.Store.CreateUser("sebastian", "einAnderesPasswort");
|
||||
|
||||
Assert.False(created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyPassword_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempUserStore();
|
||||
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
|
||||
|
||||
Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempUserStore();
|
||||
|
||||
Assert.False(temp.Store.VerifyPassword("unbekannt", "irgendwas"));
|
||||
}
|
||||
|
||||
private sealed class TempUserStore : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
|
||||
public UserStore Store { get; }
|
||||
|
||||
public TempUserStore()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
Store = new UserStore(_directory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Store.Dispose();
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher.
|
||||
/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id.
|
||||
/// </summary>
|
||||
public class AttachmentStore(string dataPath)
|
||||
{
|
||||
private readonly string _root = Path.Combine(dataPath, "attachments");
|
||||
|
||||
public async Task StoreAsync(string userId, string storageId, Stream content)
|
||||
{
|
||||
var dir = Path.Combine(_root, Safe(userId));
|
||||
Directory.CreateDirectory(dir);
|
||||
await using var file = File.Create(Path.Combine(dir, Safe(storageId)));
|
||||
await content.CopyToAsync(file);
|
||||
}
|
||||
|
||||
public Stream? OpenRead(string userId, string storageId)
|
||||
{
|
||||
var path = Path.Combine(_root, Safe(userId), Safe(storageId));
|
||||
return File.Exists(path) ? File.OpenRead(path) : null;
|
||||
}
|
||||
|
||||
// storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad
|
||||
// übernehmen (Path-Traversal).
|
||||
private static string Safe(string value) =>
|
||||
string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-'));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
internal static class Cli
|
||||
{
|
||||
public static Task<int> RunCreateUserAsync(string dataPath, string[] args)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
{
|
||||
Console.Error.WriteLine("Verwendung: create-user <benutzername> [--password <passwort>]");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
var username = args[1];
|
||||
string? password = null;
|
||||
for (var i = 2; i < args.Length - 1; i++)
|
||||
if (args[i] == "--password") password = args[i + 1];
|
||||
|
||||
password ??= ReadPassword("Passwort (mind. 12 Zeichen): ");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
|
||||
{
|
||||
Console.Error.WriteLine("Passwort muss mindestens 12 Zeichen lang sein.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(dataPath);
|
||||
using var store = new UserStore(dataPath);
|
||||
if (!store.CreateUser(username, password))
|
||||
{
|
||||
Console.Error.WriteLine($"Nutzer '{username}' existiert bereits.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Nutzer '{username}' angelegt.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
// docker exec ohne -it liefert kein TTY -> ReadKey wäre nicht möglich, dann normal lesen.
|
||||
private static string ReadPassword(string prompt)
|
||||
{
|
||||
Console.Write(prompt);
|
||||
if (Console.IsInputRedirected) return Console.ReadLine() ?? "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
ConsoleKeyInfo key;
|
||||
while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter)
|
||||
{
|
||||
if (key.Key == ConsoleKey.Backspace && sb.Length > 0) { sb.Length--; Console.Write("\b \b"); }
|
||||
else if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write('*'); }
|
||||
}
|
||||
Console.WriteLine();
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
@@ -14,21 +15,14 @@ public static class Endpoints
|
||||
|
||||
public static void MapAuthEndpoints(this WebApplication app, string secret)
|
||||
{
|
||||
app.MapPost("/api/auth/login", (LoginRequest req) =>
|
||||
app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
||||
return Results.Unauthorized();
|
||||
// TODO: Passwort gegen DB prüfen
|
||||
if (!store.VerifyPassword(req.Username, req.Password))
|
||||
return Results.Unauthorized();
|
||||
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/register", (RegisterRequest req) =>
|
||||
{
|
||||
if (req.Password.Length < 12)
|
||||
return Results.BadRequest("Passwort mind. 12 Zeichen.");
|
||||
// TODO: User anlegen, Passwort hashen (BCrypt)
|
||||
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
|
||||
});
|
||||
}).RequireRateLimiting("login");
|
||||
}
|
||||
|
||||
// ── Sync ──────────────────────────────────────────────────────────────────
|
||||
@@ -55,6 +49,30 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
|
||||
|
||||
public static void MapAttachmentEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("/api/sync/attachments").RequireAuthorization();
|
||||
g.MapPost("/{storageId}", async (string storageId, HttpRequest req,
|
||||
ClaimsPrincipal user, AttachmentStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes)
|
||||
return Results.BadRequest("Datei zu groß oder Content-Length fehlt.");
|
||||
await store.StoreAsync(uid, storageId, req.Body);
|
||||
return Results.Ok();
|
||||
});
|
||||
g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var stream = store.OpenRead(uid, storageId);
|
||||
return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
|
||||
|
||||
public static void MapSnapshotEndpoints(this WebApplication app)
|
||||
@@ -130,4 +148,3 @@ public static class Endpoints
|
||||
}
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record RegisterRequest(string Username, string Password, string DisplayName);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
private const int Iterations = 100_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
|
||||
public static string Hash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string stored)
|
||||
{
|
||||
var parts = stored.Split(':');
|
||||
if (parts.Length != 2) return false;
|
||||
var salt = Convert.FromBase64String(parts[0]);
|
||||
var expected = Convert.FromBase64String(parts[1]);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using LehrerApp.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -9,8 +11,16 @@ builder.WebHost.UseKestrel(o =>
|
||||
{
|
||||
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
||||
o.ListenAnyIP(port);
|
||||
// Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas
|
||||
// Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze.
|
||||
o.Limits.MaxRequestBodySize = 15 * 1024 * 1024;
|
||||
});
|
||||
|
||||
var data = builder.Configuration["Api:DataPath"] ?? "./data";
|
||||
|
||||
if (args.Length > 0 && args[0] == "create-user")
|
||||
return await Cli.RunCreateUserAsync(data, args);
|
||||
|
||||
var secret = builder.Configuration["JWT_SECRET"]
|
||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
||||
|
||||
@@ -24,7 +34,32 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var data = builder.Configuration["Api:DataPath"] ?? "./data";
|
||||
// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein
|
||||
// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte.
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.AddFixedWindowLimiter("login", o =>
|
||||
{
|
||||
o.PermitLimit = 5;
|
||||
o.Window = TimeSpan.FromMinutes(1);
|
||||
o.QueueLimit = 0;
|
||||
});
|
||||
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 120,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
|
||||
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
|
||||
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
|
||||
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||
@@ -32,11 +67,14 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapAuthEndpoints(secret);
|
||||
app.MapSyncEndpoints();
|
||||
app.MapAttachmentEndpoints();
|
||||
app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
app.Run();
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public class UserStore(string dataPath) : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db = new(Path.Combine(dataPath, "users.db"));
|
||||
|
||||
private ILiteCollection<UserEntry> Col
|
||||
{
|
||||
get
|
||||
{
|
||||
var col = _db.GetCollection<UserEntry>("users");
|
||||
col.EnsureIndex(x => x.Username, unique: true);
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CreateUser(string username, string password)
|
||||
{
|
||||
if (Col.Exists(x => x.Username == username)) return false;
|
||||
Col.Insert(new UserEntry { Username = username, PasswordHash = PasswordHasher.Hash(password) });
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string username, string password)
|
||||
{
|
||||
var user = Col.FindOne(x => x.Username == username);
|
||||
return user is not null && PasswordHasher.Verify(password, user.PasswordHash);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
internal class UserEntry
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string Username { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Data.Tests;
|
||||
|
||||
// Baustein 4: pro öffentlichem Repository-Aufruf genau EIN Sync-Ereignis, auch wenn die
|
||||
// eigentliche Kaskade mehrere Collections betrifft. Batch-Methoden feuern ein Ereignis pro
|
||||
// betroffener Entität.
|
||||
public sealed class ChangeHookCascadeTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
|
||||
[Fact]
|
||||
public void GroupRepository_Delete_LoestGenauEinEreignisAusTrotzKaskade()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var groupRepo = new GroupRepository(db);
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
groupRepo.Save(group);
|
||||
db.Grades.Insert(new Grade { GroupId = group.Id, StudentId = Guid.NewGuid() });
|
||||
db.Exams.Insert(new Exam { GroupId = group.Id });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
groupRepo.Delete(group.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(LearningGroup), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamRepository_Delete_LoestGenauEinEreignisAusTrotzErgebnisKaskade()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ExamRepository(db);
|
||||
var exam = new Exam { GroupId = Guid.NewGuid() };
|
||||
repo.Save(exam);
|
||||
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
|
||||
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.Delete(exam.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Exam), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParticipationSessionRepository_Delete_LoestGenauEinEreignisAusTrotzEintragKaskade()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ParticipationSessionRepository(db);
|
||||
var session = new ParticipationSession { GroupId = Guid.NewGuid() };
|
||||
repo.Save(session);
|
||||
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.Delete(session.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(ParticipationSession), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamResultRepository_SaveMany_LoestEinEreignisProEintragAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ExamResultRepository(db);
|
||||
var results = new List<ExamResult>
|
||||
{
|
||||
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
|
||||
};
|
||||
var calls = new List<(string EntityType, string EntityId, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op));
|
||||
|
||||
repo.SaveMany(results);
|
||||
|
||||
Assert.Equal(3, calls.Count);
|
||||
Assert.All(calls, c => Assert.Equal((nameof(ExamResult), "Save"), (c.EntityType, c.Operation)));
|
||||
Assert.Equal(results.Select(r => r.Id.ToString()).OrderBy(x => x), calls.Select(c => c.EntityId).OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParticipationRepository_SaveMany_LoestEinEreignisProEintragAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ParticipationRepository(db);
|
||||
var sessionId = Guid.NewGuid();
|
||||
var entries = new List<ParticipationEntry>
|
||||
{
|
||||
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
|
||||
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
|
||||
};
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.SaveMany(entries);
|
||||
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Save"), c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParticipationRepository_DeleteBySession_LoestEinEreignisProEintragAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ParticipationRepository(db);
|
||||
var sessionId = Guid.NewGuid();
|
||||
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
|
||||
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.DeleteBySession(sessionId);
|
||||
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Delete"), c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompetencyDomainRepository_ReplaceForSubjectAndGrade_LoestDeleteFuerAlteUndSaveFuerNeueAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new CompetencyDomainRepository(db);
|
||||
var subjectId = Guid.NewGuid();
|
||||
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "Alt" });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.ReplaceForSubjectAndGrade(subjectId, 8,
|
||||
[
|
||||
new CompetencyDomain { Name = "Neu 1" },
|
||||
new CompetencyDomain { Name = "Neu 2" },
|
||||
]);
|
||||
|
||||
Assert.Equal(3, calls.Count);
|
||||
Assert.Equal(1, calls.Count(c => c.Operation == "Delete"));
|
||||
Assert.Equal(2, calls.Count(c => c.Operation == "Save"));
|
||||
Assert.All(calls, c => Assert.Equal(nameof(CompetencyDomain), c.EntityType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompetencyDomainRepository_DeleteBySubjectAndGrade_LoestEinEreignisProDomaenAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new CompetencyDomainRepository(db);
|
||||
var subjectId = Guid.NewGuid();
|
||||
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "A" });
|
||||
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "B" });
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.DeleteBySubjectAndGrade(subjectId, 8);
|
||||
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.All(calls, c => Assert.Equal((nameof(CompetencyDomain), "Delete"), c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DocumentationRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new DocumentationRepository(db);
|
||||
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
|
||||
repo.Save(doc);
|
||||
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||
|
||||
repo.Delete(doc.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Documentation), "Save"), (call.EntityType, call.Operation));
|
||||
Assert.True(((Documentation)call.Payload!).IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DocumentationRepository_HardDelete_LoestDeleteAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new DocumentationRepository(db);
|
||||
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
|
||||
repo.Save(doc);
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
repo.HardDelete(doc.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Data.Tests;
|
||||
|
||||
// Baustein 3: prüft für jedes der ~19 "einfachen" Repositories (keine Kaskaden/Batches), dass
|
||||
// Save/Delete den OnChange-Hook mit dem korrekten EntityType auslöst. Tabellengetrieben statt
|
||||
// eine Datei pro Repository, um die immer gleiche Prüfung nicht 19x zu wiederholen.
|
||||
public sealed class ChangeHookMatrixTests
|
||||
{
|
||||
public static IEnumerable<object[]> Cases()
|
||||
{
|
||||
yield return Case("SeatingPlan", nameof(SeatingPlan), db =>
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
new GroupRepository(db).Save(group);
|
||||
var repo = new SeatingPlanRepository(db);
|
||||
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
|
||||
repo.Save(plan);
|
||||
return (plan.Id, () => repo.Delete(plan.Id));
|
||||
});
|
||||
|
||||
yield return Case("GroupMembership", nameof(GroupMembership), db =>
|
||||
{
|
||||
var repo = new GroupMembershipRepository(db);
|
||||
var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid() };
|
||||
repo.Save(membership);
|
||||
return (membership.Id, () => repo.Delete(membership.Id));
|
||||
});
|
||||
|
||||
yield return Case("GradingKeyTemplate", nameof(GradingKeyTemplate), db =>
|
||||
{
|
||||
var repo = new GradingKeyTemplateRepository(db);
|
||||
var template = new GradingKeyTemplate { Name = "Standard", GradingSystem = GradingSystem.Points0To15 };
|
||||
repo.Save(template);
|
||||
return (template.Id, () => repo.Delete(template.Id));
|
||||
});
|
||||
|
||||
yield return Case("Grade", nameof(Grade), db =>
|
||||
{
|
||||
var repo = new GradeRepository(db);
|
||||
var grade = new Grade { GroupId = Guid.NewGuid(), StudentId = Guid.NewGuid(), Value = "2" };
|
||||
repo.Save(grade);
|
||||
return (grade.Id, () => repo.Delete(grade.Id));
|
||||
});
|
||||
|
||||
yield return Case("GradingScheme", nameof(GradingScheme), db =>
|
||||
{
|
||||
var repo = new GradingSchemeRepository(db);
|
||||
var scheme = new GradingScheme { GroupId = Guid.NewGuid(), ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 };
|
||||
repo.Save(scheme);
|
||||
return (scheme.Id, () => repo.Delete(scheme.Id));
|
||||
});
|
||||
|
||||
yield return Case("ReportGrade", nameof(ReportGrade), db =>
|
||||
{
|
||||
var repo = new ReportGradeRepository(db);
|
||||
var grade = new ReportGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = "1. Halbjahr" };
|
||||
repo.Save(grade);
|
||||
return (grade.Id, () => repo.Delete(grade.Id));
|
||||
});
|
||||
|
||||
yield return Case("Unit", nameof(Unit), db =>
|
||||
{
|
||||
var repo = new UnitRepository(db);
|
||||
var unit = new Unit { GroupId = Guid.NewGuid(), Title = "Einheit" };
|
||||
repo.Save(unit);
|
||||
return (unit.Id, () => repo.Delete(unit.Id));
|
||||
});
|
||||
|
||||
yield return Case("Lesson", nameof(Lesson), db =>
|
||||
{
|
||||
var repo = new LessonRepository(db);
|
||||
var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 3, 12) };
|
||||
repo.Save(lesson);
|
||||
return (lesson.Id, () => repo.Delete(lesson.Id));
|
||||
});
|
||||
|
||||
yield return Case("WorkTask", nameof(WorkTask), db =>
|
||||
{
|
||||
var repo = new WorkTaskRepository(db);
|
||||
var task = new WorkTask { Title = "Klausur korrigieren", Category = TaskCategory.Correction };
|
||||
repo.Save(task);
|
||||
return (task.Id, () => repo.Delete(task.Id));
|
||||
});
|
||||
|
||||
yield return Case("TimeEntry", nameof(TimeEntry), db =>
|
||||
{
|
||||
var repo = new TimeEntryRepository(db);
|
||||
var entry = new TimeEntry { Date = new DateOnly(2026, 3, 12), DurationMinutes = 30, Category = "Vorbereitung" };
|
||||
repo.Save(entry);
|
||||
return (entry.Id, () => repo.Delete(entry.Id));
|
||||
});
|
||||
|
||||
yield return Case("ParticipationAspect", nameof(ParticipationAspect), db =>
|
||||
{
|
||||
var repo = new ParticipationAspectRepository(db);
|
||||
var aspect = new ParticipationAspect { GroupId = Guid.NewGuid(), Key = "quality", Label = "Qualität" };
|
||||
repo.Save(aspect);
|
||||
return (aspect.Id, () => repo.Delete(aspect.Id));
|
||||
});
|
||||
|
||||
yield return Case("ParticipationSection", nameof(ParticipationSection), db =>
|
||||
{
|
||||
var repo = new ParticipationSectionRepository(db);
|
||||
var section = new ParticipationSection
|
||||
{
|
||||
GroupId = Guid.NewGuid(), Label = "1. Abschnitt",
|
||||
StartDate = new DateOnly(2026, 3, 1), EndDate = new DateOnly(2026, 4, 1),
|
||||
};
|
||||
repo.Save(section);
|
||||
return (section.Id, () => repo.Delete(section.Id));
|
||||
});
|
||||
|
||||
yield return Case("Subject", nameof(Subject), db =>
|
||||
{
|
||||
var repo = new SubjectRepository(db);
|
||||
var subject = new Subject { Name = "Chemie" };
|
||||
repo.Save(subject);
|
||||
return (subject.Id, () => repo.Delete(subject.Id));
|
||||
});
|
||||
|
||||
yield return Case("ShorthandCode", nameof(ShorthandCode), db =>
|
||||
{
|
||||
var repo = new ShorthandCodeRepository(db);
|
||||
var code = new ShorthandCode { Code = "Tb", Label = "Tafelbild" };
|
||||
repo.Save(code);
|
||||
return (code.Id, () => repo.Delete(code.Id));
|
||||
});
|
||||
|
||||
yield return Case("AlternativeLessonPath", nameof(AlternativeLessonPath), db =>
|
||||
{
|
||||
var repo = new AlternativeLessonPathRepository(db);
|
||||
var path = new AlternativeLessonPath { Name = "Kurzversion" };
|
||||
repo.Save(path);
|
||||
return (path.Id, () => repo.Delete(path.Id));
|
||||
});
|
||||
|
||||
yield return Case("TimetableSlot", nameof(TimetableSlot), db =>
|
||||
{
|
||||
var repo = new TimetableSlotRepository(db);
|
||||
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
|
||||
repo.Save(slot);
|
||||
return (slot.Id, () => repo.Delete(slot.Id));
|
||||
});
|
||||
|
||||
yield return Case("SchoolHoliday", nameof(SchoolHoliday), db =>
|
||||
{
|
||||
var repo = new SchoolHolidayRepository(db);
|
||||
var holiday = new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) };
|
||||
repo.Save(holiday);
|
||||
return (holiday.Id, () => repo.Delete(holiday.Id));
|
||||
});
|
||||
|
||||
yield return Case("SupervisionDuty", nameof(SupervisionDuty), db =>
|
||||
{
|
||||
var repo = new SupervisionDutyRepository(db);
|
||||
var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" };
|
||||
repo.Save(duty);
|
||||
return (duty.Id, () => repo.Delete(duty.Id));
|
||||
});
|
||||
|
||||
yield return Case("SubstitutionEntry", nameof(SubstitutionEntry), db =>
|
||||
{
|
||||
var repo = new SubstitutionEntryRepository(db);
|
||||
var entry = new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" };
|
||||
repo.Save(entry);
|
||||
return (entry.Id, () => repo.Delete(entry.Id));
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void Save_LoestOnChangeMitKorrektemEntityTypeAus(
|
||||
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
var calls = new List<(string EntityType, string Operation)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
|
||||
|
||||
setup(db);
|
||||
|
||||
Assert.Contains((entityType, "Save"), calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void Delete_LoestOnChangeMitKorrektemEntityTypeAus(
|
||||
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
|
||||
{
|
||||
using var db = new LiteDbContext(new MemoryStream());
|
||||
var (id, delete) = setup(db);
|
||||
var calls = new List<(string EntityType, string EntityId, string Operation)>();
|
||||
db.OnChange = (type, entId, op, payload) => calls.Add((type, entId, op));
|
||||
|
||||
delete();
|
||||
|
||||
Assert.Contains((entityType, id.ToString(), "Delete"), calls);
|
||||
}
|
||||
|
||||
private static object[] Case(string name, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup) =>
|
||||
[name, entityType, setup];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Data.Tests;
|
||||
|
||||
// Vorlage-Test für den OnChange-Hook (Baustein 2), den die übrigen Repositories in den
|
||||
// Bausteinen 3/4 auf dieselbe Weise bekommen.
|
||||
public sealed class ChangeHookTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
|
||||
[Fact]
|
||||
public void StudentRepository_Save_LoestOnChangeMitKorrektenArgumentenAus()
|
||||
{
|
||||
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 StudentRepository(db);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
|
||||
repo.Save(student);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal("Student", call.EntityType);
|
||||
Assert.Equal(student.Id.ToString(), call.EntityId);
|
||||
Assert.Equal("Save", call.Operation);
|
||||
Assert.Same(student, call.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StudentRepository_Delete_LoestOnChangeMitNullPayloadAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new StudentRepository(db);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
repo.Save(student);
|
||||
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||
|
||||
repo.Delete(student.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal("Student", call.EntityType);
|
||||
Assert.Equal(student.Id.ToString(), call.EntityId);
|
||||
Assert.Equal("Delete", call.Operation);
|
||||
Assert.Null(call.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StudentRepository_Save_OhneGesetztenHook_WirftNicht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new StudentRepository(db);
|
||||
|
||||
var exception = Record.Exception(() => repo.Save(new Student { FirstName = "Anna", LastName = "Beispiel" }));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext
|
||||
// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*).
|
||||
[assembly: InternalsVisibleTo("LehrerApp.Sync")]
|
||||
@@ -3,6 +3,10 @@ using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Data;
|
||||
|
||||
/// Wird nach jedem Save/Delete einer Entität aufgerufen; payload ist die gespeicherte Entität
|
||||
/// bzw. null bei Delete. Sync-agnostisch – siehe <see cref="LiteDbContext.OnChange"/>.
|
||||
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||||
/// </summary>
|
||||
@@ -67,6 +71,117 @@ public class LiteDbContext : IDisposable
|
||||
|
||||
public int SchemaVersion => ReadSchemaVersion();
|
||||
|
||||
/// Wird von den Repositories nach jedem Save/Delete aufgerufen (payload = die gespeicherte
|
||||
/// Entität bzw. null bei Delete). Bewusst hier statt in LehrerApp.Sync definiert, damit Data
|
||||
/// weiterhin ohne Verweis auf Sync auskommt — die eigentliche Sync-Anbindung setzt diesen
|
||||
/// Hook von außen (siehe AppBootstrapper).
|
||||
public ChangeHandler? OnChange { get; set; }
|
||||
|
||||
/// Führt dieselbe Kaskade wie <c>GroupRepository.Delete</c> aus. Hier auf dem Context statt
|
||||
/// im Repository, damit ein später eingehendes Sync-Ereignis (Baustein 5) dieselbe Kaskade
|
||||
/// nachvollziehen kann, ohne über die Repository-Save/Delete-Methoden (und damit erneut über
|
||||
/// <see cref="OnChange"/>) zu laufen.
|
||||
internal void CascadeDeleteGroup(Guid id)
|
||||
{
|
||||
ExecuteInTransaction(() =>
|
||||
{
|
||||
foreach (var membership in Memberships.Find(e => e.GroupId == id).ToList())
|
||||
Memberships.Delete(membership.Id);
|
||||
|
||||
foreach (var exam in Exams.Find(e => e.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var result in ExamResults.Find(r => r.ExamId == exam.Id).ToList())
|
||||
ExamResults.Delete(result.Id);
|
||||
Exams.Delete(exam.Id);
|
||||
}
|
||||
|
||||
foreach (var grade in Grades.Find(g => g.GroupId == id).ToList())
|
||||
Grades.Delete(grade.Id);
|
||||
|
||||
foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList())
|
||||
ReportGrades.Delete(reportGrade.Id);
|
||||
|
||||
foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList())
|
||||
GradingSchemes.Delete(scheme.Id);
|
||||
|
||||
foreach (var unit in Units.Find(u => u.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var lesson in Lessons.Find(l => l.UnitId == unit.Id).ToList())
|
||||
Lessons.Delete(lesson.Id);
|
||||
Units.Delete(unit.Id);
|
||||
}
|
||||
|
||||
foreach (var lesson in Lessons.Find(l => l.GroupId == id).ToList())
|
||||
Lessons.Delete(lesson.Id);
|
||||
|
||||
foreach (var session in ParticipationSessions.Find(s => s.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var entry in ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
|
||||
ParticipationEntries.Delete(entry.Id);
|
||||
ParticipationSessions.Delete(session.Id);
|
||||
}
|
||||
|
||||
foreach (var aspect in ParticipationAspects.Find(a => a.GroupId == id).ToList())
|
||||
ParticipationAspects.Delete(aspect.Id);
|
||||
|
||||
foreach (var section in ParticipationSections.Find(s => s.GroupId == id).ToList())
|
||||
ParticipationSections.Delete(section.Id);
|
||||
|
||||
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
|
||||
SeatingPlans.Delete(plan.Id);
|
||||
|
||||
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
|
||||
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
|
||||
foreach (var documentation in Documentation.Find(d => d.GroupId == id).ToList())
|
||||
{
|
||||
documentation.GroupId = null;
|
||||
documentation.UpdatedAt = DateTime.UtcNow;
|
||||
Documentation.Update(documentation);
|
||||
}
|
||||
|
||||
foreach (var task in Tasks.Find(t => t.GroupId == id).ToList())
|
||||
{
|
||||
task.GroupId = null;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
Tasks.Update(task);
|
||||
}
|
||||
|
||||
foreach (var timeEntry in TimeEntries.Find(t => t.GroupId == id).ToList())
|
||||
{
|
||||
timeEntry.GroupId = null;
|
||||
TimeEntries.Update(timeEntry);
|
||||
}
|
||||
|
||||
Groups.Delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Führt dieselbe Kaskade wie <c>ExamRepository.Delete</c> aus (siehe <see cref="CascadeDeleteGroup"/>).
|
||||
internal void CascadeDeleteExam(Guid id)
|
||||
{
|
||||
foreach (var result in ExamResults.Find(r => r.ExamId == id).ToList())
|
||||
ExamResults.Delete(result.Id);
|
||||
Exams.Delete(id);
|
||||
}
|
||||
|
||||
/// Führt dieselbe Kaskade wie <c>ParticipationSessionRepository.Delete</c> aus (siehe
|
||||
/// <see cref="CascadeDeleteGroup"/>).
|
||||
internal void CascadeDeleteParticipationSession(Guid id)
|
||||
{
|
||||
ParticipationSessions.Delete(id);
|
||||
foreach (var e in ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
||||
ParticipationEntries.Delete(e.Id);
|
||||
}
|
||||
|
||||
/// Führt dieselbe Kaskade wie <c>DocumentationRepository.HardDelete</c> aus (siehe
|
||||
/// <see cref="CascadeDeleteGroup"/>).
|
||||
internal void CascadeHardDeleteDocumentation(Guid id)
|
||||
{
|
||||
if (Documentation.FindById(id) is { } doc)
|
||||
foreach (var attachment in doc.Attachments) Attachments.Delete(attachment.StorageId);
|
||||
Documentation.Delete(id);
|
||||
}
|
||||
|
||||
internal void ExecuteInTransaction(Action action)
|
||||
{
|
||||
_db.BeginTrans();
|
||||
|
||||
@@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
||||
db.ReportGrades.Count(g => g.StudentId == studentId),
|
||||
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
||||
db.Documentation.Count(d => d.StudentId == studentId));
|
||||
public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); }
|
||||
public void Save(Student s)
|
||||
{
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
db.Students.Upsert(s);
|
||||
db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var references = GetReferenceSummary(id);
|
||||
@@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
||||
throw new InvalidOperationException(
|
||||
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
||||
db.Students.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,81 +79,13 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
|
||||
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
||||
g.UpdatedAt = DateTime.UtcNow;
|
||||
db.Groups.Upsert(g);
|
||||
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
||||
db.ExecuteInTransaction(() =>
|
||||
{
|
||||
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
|
||||
db.Memberships.Delete(membership.Id);
|
||||
|
||||
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
|
||||
db.ExamResults.Delete(result.Id);
|
||||
db.Exams.Delete(exam.Id);
|
||||
}
|
||||
|
||||
foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList())
|
||||
db.Grades.Delete(grade.Id);
|
||||
|
||||
foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList())
|
||||
db.ReportGrades.Delete(reportGrade.Id);
|
||||
|
||||
foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList())
|
||||
db.GradingSchemes.Delete(scheme.Id);
|
||||
|
||||
foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList())
|
||||
db.Lessons.Delete(lesson.Id);
|
||||
db.Units.Delete(unit.Id);
|
||||
}
|
||||
|
||||
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
|
||||
db.Lessons.Delete(lesson.Id);
|
||||
|
||||
foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
|
||||
{
|
||||
foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
|
||||
db.ParticipationEntries.Delete(entry.Id);
|
||||
db.ParticipationSessions.Delete(session.Id);
|
||||
}
|
||||
|
||||
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
|
||||
db.ParticipationAspects.Delete(aspect.Id);
|
||||
|
||||
foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
|
||||
db.ParticipationSections.Delete(section.Id);
|
||||
|
||||
foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList())
|
||||
db.SeatingPlans.Delete(plan.Id);
|
||||
|
||||
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
|
||||
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
|
||||
foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList())
|
||||
{
|
||||
documentation.GroupId = null;
|
||||
documentation.UpdatedAt = DateTime.UtcNow;
|
||||
db.Documentation.Update(documentation);
|
||||
}
|
||||
|
||||
foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList())
|
||||
{
|
||||
task.GroupId = null;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
db.Tasks.Update(task);
|
||||
}
|
||||
|
||||
foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList())
|
||||
{
|
||||
timeEntry.GroupId = null;
|
||||
db.TimeEntries.Update(timeEntry);
|
||||
}
|
||||
|
||||
db.Groups.Delete(id);
|
||||
});
|
||||
db.CascadeDeleteGroup(id);
|
||||
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +134,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
||||
|
||||
plan.UpdatedAt = DateTime.UtcNow;
|
||||
db.SeatingPlans.Upsert(plan);
|
||||
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
|
||||
}
|
||||
|
||||
public void Delete(Guid id)
|
||||
@@ -203,6 +142,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
||||
if (db.SeatingPlans.FindById(id) is { } plan)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||
db.SeatingPlans.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,12 +161,14 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos
|
||||
if (existing is not null && existing.Id != membership.Id)
|
||||
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
||||
db.Memberships.Upsert(membership);
|
||||
db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Memberships.FindById(id) is { } membership)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
||||
db.Memberships.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +183,14 @@ public class ExamRepository(LiteDbContext db) : IExamRepository
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
|
||||
e.UpdatedAt = DateTime.UtcNow;
|
||||
db.Exams.Upsert(e);
|
||||
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Exams.FindById(id) is { } exam)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
|
||||
db.ExamResults.Delete(result.Id);
|
||||
db.Exams.Delete(id);
|
||||
db.CascadeDeleteExam(id);
|
||||
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +208,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||
r.UpdatedAt = DateTime.UtcNow;
|
||||
db.ExamResults.Upsert(r);
|
||||
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
|
||||
}
|
||||
public void SaveMany(List<ExamResult> results)
|
||||
{
|
||||
@@ -275,6 +218,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var r in results) r.UpdatedAt = now;
|
||||
db.ExamResults.Upsert(results);
|
||||
foreach (var r in results)
|
||||
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,8 +230,17 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
|
||||
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
|
||||
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
|
||||
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
|
||||
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); }
|
||||
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id);
|
||||
public void Save(GradingKeyTemplate t)
|
||||
{
|
||||
t.UpdatedAt = DateTime.UtcNow;
|
||||
db.GradingKeyTemplates.Upsert(t);
|
||||
db.OnChange?.Invoke(nameof(GradingKeyTemplate), t.Id.ToString(), "Save", t);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.GradingKeyTemplates.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||
@@ -299,12 +253,14 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
|
||||
db.Grades.Upsert(g);
|
||||
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Grades.FindById(id) is { } grade)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||
db.Grades.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,12 +274,14 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
|
||||
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
db.GradingSchemes.Upsert(s);
|
||||
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||
db.GradingSchemes.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,12 +295,14 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
|
||||
r.UpdatedAt = DateTime.UtcNow;
|
||||
db.ReportGrades.Upsert(r);
|
||||
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.ReportGrades.FindById(id) is { } grade)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||
db.ReportGrades.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,12 +316,14 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||
u.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(u);
|
||||
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Units.FindById(id) is { } unit)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
|
||||
db.Units.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,12 +341,14 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
|
||||
l.UpdatedAt = DateTime.UtcNow;
|
||||
db.Lessons.Upsert(l);
|
||||
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Lessons.FindById(id) is { } lesson)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
||||
db.Lessons.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +361,12 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
||||
.OrderByDescending(d => d.Date).ToList();
|
||||
public List<Documentation> GetAll() =>
|
||||
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
|
||||
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); }
|
||||
public void Save(Documentation d)
|
||||
{
|
||||
d.UpdatedAt = DateTime.UtcNow;
|
||||
db.Documentation.Upsert(d);
|
||||
db.OnChange?.Invoke(nameof(Documentation), d.Id.ToString(), "Save", d);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var doc = db.Documentation.FindById(id);
|
||||
@@ -405,13 +374,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
||||
doc.IsDeleted = true;
|
||||
doc.DeletedAt = DateTime.UtcNow;
|
||||
db.Documentation.Update(doc);
|
||||
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen.
|
||||
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Save", doc);
|
||||
}
|
||||
public void HardDelete(Guid id)
|
||||
{
|
||||
var doc = db.Documentation.FindById(id);
|
||||
if (doc is not null)
|
||||
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
|
||||
db.Documentation.Delete(id);
|
||||
db.CascadeHardDeleteDocumentation(id);
|
||||
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,8 +391,17 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
||||
public List<WorkTask> GetAll() =>
|
||||
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
||||
public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); }
|
||||
public void Delete(Guid id) => db.Tasks.Delete(id);
|
||||
public void Save(WorkTask t)
|
||||
{
|
||||
t.UpdatedAt = DateTime.UtcNow;
|
||||
db.Tasks.Upsert(t);
|
||||
db.OnChange?.Invoke(nameof(WorkTask), t.Id.ToString(), "Save", t);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.Tasks.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
||||
@@ -433,8 +412,16 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
|
||||
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
|
||||
public List<TimeEntry> GetByTask(Guid id) =>
|
||||
db.TimeEntries.Find(e => e.TaskId == id).ToList();
|
||||
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e);
|
||||
public void Delete(Guid id) => db.TimeEntries.Delete(id);
|
||||
public void Save(TimeEntry e)
|
||||
{
|
||||
db.TimeEntries.Upsert(e);
|
||||
db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.TimeEntries.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
|
||||
@@ -447,14 +434,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
db.ParticipationSessions.Upsert(s);
|
||||
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.ParticipationSessions.FindById(id) is { } session)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||
db.ParticipationSessions.Delete(id);
|
||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
||||
db.ParticipationEntries.Delete(e.Id);
|
||||
db.CascadeDeleteParticipationSession(id);
|
||||
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +459,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||
e.UpdatedAt = DateTime.UtcNow;
|
||||
db.ParticipationEntries.Upsert(e);
|
||||
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||
}
|
||||
public void SaveMany(List<ParticipationEntry> entries)
|
||||
{
|
||||
@@ -481,13 +469,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var e in entries) e.UpdatedAt = now;
|
||||
db.ParticipationEntries.Upsert(entries);
|
||||
foreach (var e in entries)
|
||||
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||
}
|
||||
public void DeleteBySession(Guid sessionId)
|
||||
{
|
||||
if (db.ParticipationSessions.FindById(sessionId) is { } session)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
||||
{
|
||||
db.ParticipationEntries.Delete(e.Id);
|
||||
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,12 +510,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
|
||||
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
db.ParticipationAspects.Upsert(a);
|
||||
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||
db.ParticipationAspects.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,12 +529,14 @@ public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSe
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
db.ParticipationSections.Upsert(s);
|
||||
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.ParticipationSections.FindById(id) is { } section)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
|
||||
db.ParticipationSections.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,12 +556,14 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
|
||||
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
db.Subjects.Upsert(s);
|
||||
db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id))
|
||||
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
|
||||
db.Subjects.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,8 +581,13 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor
|
||||
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
|
||||
c.UpdatedAt = DateTime.UtcNow;
|
||||
db.ShorthandCodes.Upsert(c);
|
||||
db.OnChange?.Invoke(nameof(ShorthandCode), c.Id.ToString(), "Save", c);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.ShorthandCodes.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(ShorthandCode), id.ToString(), "Delete", null);
|
||||
}
|
||||
public void Delete(Guid id) => db.ShorthandCodes.Delete(id);
|
||||
}
|
||||
|
||||
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
|
||||
@@ -601,8 +605,13 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
|
||||
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
|
||||
p.UpdatedAt = DateTime.UtcNow;
|
||||
db.AlternativeLessonPaths.Upsert(p);
|
||||
db.OnChange?.Invoke(nameof(AlternativeLessonPath), p.Id.ToString(), "Save", p);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.AlternativeLessonPaths.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(AlternativeLessonPath), id.ToString(), "Delete", null);
|
||||
}
|
||||
public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
|
||||
}
|
||||
|
||||
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
|
||||
@@ -619,20 +628,30 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor
|
||||
if (occupied is not null && occupied.Id != slot.Id)
|
||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||
db.TimetableSlots.Upsert(slot);
|
||||
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
if (db.TimetableSlots.FindById(id) is { } slot)
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
||||
db.TimetableSlots.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
||||
{
|
||||
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
||||
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
|
||||
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
|
||||
public void Save(SchoolHoliday holiday)
|
||||
{
|
||||
db.SchoolHolidays.Upsert(holiday);
|
||||
db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.SchoolHolidays.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
|
||||
@@ -646,8 +665,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos
|
||||
if (occupied is not null && occupied.Id != duty.Id)
|
||||
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
|
||||
db.SupervisionDuties.Upsert(duty);
|
||||
db.OnChange?.Invoke(nameof(SupervisionDuty), duty.Id.ToString(), "Save", duty);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.SupervisionDuties.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(SupervisionDuty), id.ToString(), "Delete", null);
|
||||
}
|
||||
public void Delete(Guid id) => db.SupervisionDuties.Delete(id);
|
||||
}
|
||||
|
||||
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
|
||||
@@ -655,8 +679,16 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
|
||||
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
|
||||
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
|
||||
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
|
||||
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry);
|
||||
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id);
|
||||
public void Save(SubstitutionEntry entry)
|
||||
{
|
||||
db.SubstitutionEntries.Upsert(entry);
|
||||
db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.SubstitutionEntries.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
@@ -667,14 +699,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.ToList();
|
||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); }
|
||||
public void Delete(Guid id) => db.CompetencyDomains.Delete(id);
|
||||
public void Save(CompetencyDomain d)
|
||||
{
|
||||
d.UpdatedAt = DateTime.UtcNow;
|
||||
db.CompetencyDomains.Upsert(d);
|
||||
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Save", d);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
db.CompetencyDomains.Delete(id);
|
||||
db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null);
|
||||
}
|
||||
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
|
||||
{
|
||||
foreach (var d in db.CompetencyDomains
|
||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||
.ToList())
|
||||
{
|
||||
db.CompetencyDomains.Delete(d.Id);
|
||||
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null);
|
||||
}
|
||||
}
|
||||
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
|
||||
{
|
||||
@@ -682,7 +726,10 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
{
|
||||
foreach (var domain in db.CompetencyDomains
|
||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
|
||||
{
|
||||
db.CompetencyDomains.Delete(domain.Id);
|
||||
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var domain in domains)
|
||||
{
|
||||
@@ -690,6 +737,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
domain.GradeLevel = gradeLevel;
|
||||
domain.UpdatedAt = now;
|
||||
db.CompetencyDomains.Upsert(domain);
|
||||
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Sync;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -26,6 +27,26 @@ public static class TestSupport
|
||||
public static AiPlanningService BuildAiPlanningService() => new(
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static SyncSettingsService BuildSyncSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new SyncSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe
|
||||
/// BuildAiPlanningService).
|
||||
public static SyncAuthService BuildSyncAuthService() => new(new HttpClient());
|
||||
|
||||
/// Eigenes Temp-Verzeichnis je Aufruf (echte, dateibasierte LiteDB wie bei EventQueue üblich).
|
||||
public static EventQueue BuildEventQueue()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-eventqueue-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
|
||||
@@ -2,6 +2,9 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests
|
||||
{
|
||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null,
|
||||
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null)
|
||||
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
|
||||
EventQueue? eventQueue = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
@@ -26,7 +30,48 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
eventQueue ?? TestSupport.BuildEventQueue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncConflicts_ZeigtUnreviewedKonflikteBeimLaden()
|
||||
{
|
||||
var queue = TestSupport.BuildEventQueue();
|
||||
var conflict = new ConflictEntry
|
||||
{
|
||||
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||
Resolution = "RemoteWon",
|
||||
};
|
||||
queue.AddConflict(conflict);
|
||||
|
||||
var vm = BuildViewModel(eventQueue: queue);
|
||||
|
||||
var item = Assert.Single(vm.SyncConflicts);
|
||||
Assert.Equal(conflict.Id, item.Id);
|
||||
Assert.Contains("anderen Gerät", item.ResolutionDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkConflictReviewed_EntferntKonfliktAusListeUndAusDerQueue()
|
||||
{
|
||||
var queue = TestSupport.BuildEventQueue();
|
||||
var conflict = new ConflictEntry
|
||||
{
|
||||
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
|
||||
Resolution = "LocalWon",
|
||||
};
|
||||
queue.AddConflict(conflict);
|
||||
var vm = BuildViewModel(eventQueue: queue);
|
||||
var item = vm.SyncConflicts.Single();
|
||||
|
||||
vm.MarkConflictReviewedCommand.Execute(item);
|
||||
|
||||
Assert.Empty(vm.SyncConflicts);
|
||||
Assert.Empty(queue.GetUnreviewed());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,7 +147,8 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
|
||||
|
||||
vm.SelectedStateName = "Bayern";
|
||||
|
||||
@@ -124,7 +170,8 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:00";
|
||||
vm.PeriodTimes[0].EndText = "08:45";
|
||||
@@ -150,7 +197,8 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:45";
|
||||
vm.PeriodTimes[0].EndText = "08:00";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SyncSettingsServiceTests
|
||||
{
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettingssvc-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetServerUrl_PersistiertUeberNeueInstanz()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new SyncSettingsService(path).SetServerUrl("https://sync.example.com");
|
||||
|
||||
var reloaded = new SyncSettingsService(path);
|
||||
|
||||
Assert.Equal("https://sync.example.com", reloaded.ServerUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar()
|
||||
{
|
||||
var service = new SyncSettingsService(BuildTempPath());
|
||||
|
||||
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||
|
||||
Assert.True(service.IsLoggedIn);
|
||||
Assert.Equal("sebastian", service.Username);
|
||||
Assert.Equal("geheimes-token-123", service.GetToken());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_UeberlebtNeueInstanzMitDemselbenPfad()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new SyncSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc");
|
||||
|
||||
var reloaded = new SyncSettingsService(path);
|
||||
|
||||
Assert.True(reloaded.IsLoggedIn);
|
||||
Assert.Equal("token-abc", reloaded.GetToken());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenDateiEnthaeltNichtDenKlartext()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
var service = new SyncSettingsService(path);
|
||||
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||
|
||||
var raw = File.ReadAllText(Path.Combine(path, "sync-settings.json"));
|
||||
|
||||
Assert.DoesNotContain("geheimes-token-123", raw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
|
||||
{
|
||||
var service = new SyncSettingsService(BuildTempPath());
|
||||
service.SetServerUrl("https://sync.example.com");
|
||||
service.SetCredentialsAndToken("sebastian", "token-abc");
|
||||
|
||||
service.Logout();
|
||||
|
||||
Assert.False(service.IsLoggedIn);
|
||||
Assert.Null(service.GetToken());
|
||||
Assert.Equal("https://sync.example.com", service.ServerUrl);
|
||||
Assert.Equal("sebastian", service.Username);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,10 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<AiPlanningService>();
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
var syncSettings = new SyncSettingsService(appData);
|
||||
services.AddSingleton(syncSettings);
|
||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||
|
||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||
services.AddSingleton<byte[]>(_ =>
|
||||
@@ -173,15 +177,26 @@ public static class AppBootstrapper
|
||||
return key;
|
||||
});
|
||||
|
||||
var serverUrl = LoadServerUrl(appData);
|
||||
var serverUrl = syncSettings.ServerUrl;
|
||||
var deviceId = LoadOrCreateDeviceId(appData);
|
||||
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
services.AddSingleton(sp => new EventApplier(
|
||||
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
|
||||
BuildHttp(serverUrl, syncSettings)));
|
||||
services.AddSingleton(sp => new SyncEventPublisher(
|
||||
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
|
||||
services.AddSingleton(sp => new AttachmentSyncer(
|
||||
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, syncSettings),
|
||||
sp.GetRequiredService<byte[]>()));
|
||||
|
||||
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
|
||||
sp.GetRequiredService<EventQueue>(),
|
||||
sp.GetRequiredService<ConflictResolver>(),
|
||||
BuildHttp(serverUrl, appData),
|
||||
sp.GetRequiredService<EventApplier>(),
|
||||
sp.GetRequiredService<AttachmentSyncer>(),
|
||||
BuildHttp(serverUrl, syncSettings),
|
||||
new SyncConfig
|
||||
{
|
||||
ServerUrl = serverUrl,
|
||||
@@ -191,7 +206,7 @@ public static class AppBootstrapper
|
||||
}));
|
||||
|
||||
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
|
||||
BuildHttp(serverUrl, appData),
|
||||
BuildHttp(serverUrl, syncSettings),
|
||||
sp.GetRequiredService<LiteDbContext>(),
|
||||
sp.GetRequiredService<byte[]>(),
|
||||
DeviceType.Desktop, DbPath, keyPath));
|
||||
@@ -223,30 +238,29 @@ public static class AppBootstrapper
|
||||
services.AddTransient<AddGroupDialogViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
provider.GetRequiredService<LiteDbContext>().OnChange =
|
||||
provider.GetRequiredService<SyncEventPublisher>().Publish;
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private static HttpClient BuildHttp(string url, string appData)
|
||||
private static HttpClient BuildHttp(string url, SyncSettingsService syncSettings)
|
||||
{
|
||||
var http = new HttpClient { BaseAddress = new Uri(url) };
|
||||
var tokenPath = Path.Combine(appData, "auth.token");
|
||||
if (File.Exists(tokenPath))
|
||||
var token = syncSettings.GetToken();
|
||||
if (token is not null)
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", File.ReadAllText(tokenPath).Trim());
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
return http;
|
||||
}
|
||||
|
||||
public static string LoadServerUrl(string? path = null) =>
|
||||
File.Exists(Path.Combine(path ?? AppDataPath, "server.txt"))
|
||||
? File.ReadAllText(Path.Combine(path ?? AppDataPath, "server.txt")).Trim()
|
||||
: "";
|
||||
|
||||
public static void SaveServerUrl(string url) =>
|
||||
File.WriteAllText(Path.Combine(AppDataPath, "server.txt"), url);
|
||||
|
||||
private static string LoadOrCreateDeviceId(string appData)
|
||||
{
|
||||
var p = Path.Combine(appData, "device.id");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public class SyncAuthException(string userMessage) : Exception(userMessage);
|
||||
|
||||
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
|
||||
|
||||
/// <summary>
|
||||
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
|
||||
/// bestehenden <see cref="AiPlanningService"/> (anderes Backend, anderer Wire-Vertrag). Nimmt die
|
||||
/// Server-URL je Aufruf entgegen statt fest im Konstruktor, weil sie über die Einstellungen zur
|
||||
/// Laufzeit geändert werden kann (anders als die feste KI-Backend-URL).
|
||||
/// </summary>
|
||||
public class SyncAuthService(HttpClient http)
|
||||
{
|
||||
public async Task<string> LoginAsync(string serverUrl, string username, string password)
|
||||
{
|
||||
HttpResponseMessage resp;
|
||||
try
|
||||
{
|
||||
resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password });
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new SyncAuthException(
|
||||
"Der Sync-Server ist nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new SyncAuthException("Benutzername oder Passwort ist falsch.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
|
||||
|
||||
var result = await resp.Content.ReadFromJsonAsync<LoginResult>();
|
||||
return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.");
|
||||
}
|
||||
|
||||
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
|
||||
if (token is not null)
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await http.SendAsync(req);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
|
||||
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
|
||||
}
|
||||
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
|
||||
}
|
||||
|
||||
private static string CombineUrl(string serverUrl, string path) =>
|
||||
new Uri(new Uri(serverUrl), path).ToString();
|
||||
|
||||
private record LoginResult(string Token, string UserId);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal class SyncSettingsConfig
|
||||
{
|
||||
public string ServerUrl { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string? EncryptedToken { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt die früheren Klartext-Helfer (<c>AppBootstrapper.LoadServerUrl</c>/<c>SaveServerUrl</c>,
|
||||
/// unverschlüsselte <c>auth.token</c>-Datei). Token liegt wie beim KI-Backend-Token (siehe
|
||||
/// <see cref="AiSettingsService"/>) AES-256-verschlüsselt über <see cref="SyncCrypto"/>, mit
|
||||
/// eigenem, rein lokalem Schlüssel — nicht zu verwechseln mit dem in <c>AppBootstrapper</c>
|
||||
/// separat verwalteten Sync-Schlüssel (<c>sync.key</c>), der die Ereignis-Payloads zwischen den
|
||||
/// gepaarten Geräten verschlüsselt.
|
||||
/// </summary>
|
||||
public class SyncSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly string _keyPath;
|
||||
private readonly byte[] _tokenKey;
|
||||
private SyncSettingsConfig _config;
|
||||
|
||||
public string ServerUrl => _config.ServerUrl;
|
||||
public string Username => _config.Username;
|
||||
public bool IsLoggedIn => _config.EncryptedToken is not null;
|
||||
|
||||
public SyncSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "sync-settings.json");
|
||||
_keyPath = Path.Combine(appDataPath, "sync-token.key");
|
||||
_tokenKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetServerUrl(string url)
|
||||
{
|
||||
_config.ServerUrl = url.Trim();
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetCredentialsAndToken(string username, string token)
|
||||
{
|
||||
_config.Username = username;
|
||||
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
|
||||
Save();
|
||||
}
|
||||
|
||||
public string? GetToken() =>
|
||||
_config.EncryptedToken is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedToken, _tokenKey);
|
||||
|
||||
public void Logout()
|
||||
{
|
||||
_config.EncryptedToken = null;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey()
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, _keyPath);
|
||||
return key;
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
|
||||
private SyncSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<SyncSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new SyncSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||
return new SyncSettingsConfig();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
@@ -164,6 +165,17 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||
|
||||
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _syncServerUrl = "";
|
||||
[ObservableProperty] private string _syncUsername = "";
|
||||
[ObservableProperty] private string _syncPassword = "";
|
||||
[ObservableProperty] private string _syncLoginError = "";
|
||||
[ObservableProperty] private bool _syncIsLoggedIn;
|
||||
[ObservableProperty] private string _syncConnectionStatus = "";
|
||||
|
||||
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
@@ -172,6 +184,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly SyncSettingsService _syncSettings;
|
||||
private readonly SyncAuthService _syncAuth;
|
||||
private readonly EventQueue _eventQueue;
|
||||
private readonly CompetencyCatalogImportService _catalogImport;
|
||||
|
||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||
@@ -182,7 +197,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning)
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
@@ -204,6 +220,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_letterTemplates = letterTemplates;
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_syncSettings = syncSettings;
|
||||
_syncAuth = syncAuth;
|
||||
_eventQueue = eventQueue;
|
||||
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||||
LoadSubjects();
|
||||
LoadShorthandCodes();
|
||||
@@ -221,6 +240,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSupervisionDuties();
|
||||
LoadLetterTemplates();
|
||||
LoadAiSettings();
|
||||
LoadSyncSettings();
|
||||
LoadSyncConflicts();
|
||||
}
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
@@ -362,6 +383,86 @@ public partial class SettingsViewModel : ObservableObject
|
||||
AiBalanceDisplay = "";
|
||||
}
|
||||
|
||||
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
|
||||
//
|
||||
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
|
||||
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
|
||||
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
|
||||
|
||||
private void LoadSyncSettings()
|
||||
{
|
||||
SyncServerUrl = _syncSettings.ServerUrl;
|
||||
SyncUsername = _syncSettings.Username;
|
||||
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncTestConnection()
|
||||
{
|
||||
SyncConnectionStatus = "Teste Verbindung…";
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl))
|
||||
{
|
||||
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
|
||||
return;
|
||||
}
|
||||
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
|
||||
SyncConnectionStatus = result switch
|
||||
{
|
||||
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
||||
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
|
||||
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
|
||||
};
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncLogin()
|
||||
{
|
||||
SyncLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
||||
_syncSettings.SetServerUrl(SyncServerUrl);
|
||||
_syncSettings.SetCredentialsAndToken(SyncUsername, token);
|
||||
SyncPassword = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SyncLogout()
|
||||
{
|
||||
_syncSettings.Logout();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
||||
//
|
||||
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
||||
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
|
||||
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
|
||||
|
||||
private void LoadSyncConflicts()
|
||||
{
|
||||
SyncConflicts.Clear();
|
||||
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
|
||||
SyncConflicts.Add(new SyncConflictListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void MarkConflictReviewed(SyncConflictListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_eventQueue.MarkReviewed(item.Id);
|
||||
SyncConflicts.Remove(item);
|
||||
}
|
||||
|
||||
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||
|
||||
private void LoadPeriodTimes()
|
||||
@@ -1221,6 +1322,19 @@ public class ExpiredDocumentItem(Documentation d, string studentName)
|
||||
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
|
||||
}
|
||||
|
||||
public class SyncConflictListItem(ConflictEntry c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
|
||||
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
|
||||
public string ResolutionDisplay { get; } = c.Resolution switch
|
||||
{
|
||||
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
|
||||
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
|
||||
_ => c.Resolution,
|
||||
};
|
||||
}
|
||||
|
||||
public class SchoolHolidayItem(SchoolHoliday h)
|
||||
{
|
||||
public Guid Id { get; } = h.Id;
|
||||
|
||||
@@ -759,6 +759,83 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: Synchronisation (Kapitel 10) -->
|
||||
<ContentPage Header="Synchronisation">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
|
||||
|
||||
<TextBlock Text="Geräte-Synchronisation" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !SyncIsLoggedIn}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding SyncUsername}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding SyncPassword}" PasswordChar="●"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SyncLoginError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding SyncLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Anmelden" Command="{Binding SyncLoginCommand}"/>
|
||||
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SyncIsLoggedIn}">
|
||||
<TextBlock FontSize="13" FontWeight="SemiBold">
|
||||
<Run Text="Angemeldet als: "/><Run Text="{Binding SyncUsername}"/>
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
|
||||
<Button Content="Abmelden" Command="{Binding SyncLogoutCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding SyncConnectionStatus}" FontSize="12"
|
||||
IsVisible="{Binding SyncConnectionStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
|
||||
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
|
||||
|
||||
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
|
||||
IsVisible="{Binding SyncConflicts.Count}"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
IsVisible="{Binding SyncConflicts.Count}"
|
||||
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht — hier steht, welche Seite jeweils übernommen wurde."/>
|
||||
<ItemsControl ItemsSource="{Binding SyncConflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SyncConflictListItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="0,7">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding EntityDisplay}" FontSize="13"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6">
|
||||
<Run Text="{Binding DetectedAtDisplay}"/><Run Text=" · "/><Run Text="{Binding ResolutionDisplay}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Gesehen" FontSize="12" Padding="10,4"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).MarkConflictReviewedCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using Xunit;
|
||||
|
||||
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
|
||||
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
|
||||
// Testklassen hinweg (EventApplierTests/AttachmentSyncerTests konstruieren beide LiteDbContext).
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,76 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class AttachmentSyncerTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||
|
||||
[Fact]
|
||||
public async Task UploadPendingAsync_LaedtVerschluesselteBytesHochUndMarkiertAlsErledigt()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
var storageId = Guid.NewGuid().ToString("N");
|
||||
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3, 4]));
|
||||
temp.Queue.QueueAttachmentUpload(storageId);
|
||||
// Bytes MÜSSEN synchron innerhalb des Handler-Callbacks gelesen werden: AttachmentSyncer
|
||||
// disposed sein ByteArrayContent direkt nach dem PostAsync-Aufruf (eigenes "using"),
|
||||
// ein Zugriff auf request.Content danach würde ObjectDisposedException werfen.
|
||||
byte[]? uploadedEncrypted = null;
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
uploadedEncrypted = req.Content!.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
|
||||
});
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var syncer = new AttachmentSyncer(db, http, Key);
|
||||
|
||||
await syncer.UploadPendingAsync(temp.Queue);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
|
||||
Assert.NotNull(uploadedEncrypted);
|
||||
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
|
||||
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadPendingAsync_LokalNichtMehrVorhandenerAnhang_WirdOhneUploadAlsErledigtMarkiert()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
temp.Queue.QueueAttachmentUpload("laengst-geloescht");
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var syncer = new AttachmentSyncer(db, http, Key);
|
||||
|
||||
await syncer.UploadPendingAsync(temp.Queue);
|
||||
|
||||
Assert.Empty(handler.Requests);
|
||||
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
||||
}
|
||||
|
||||
private sealed class TempEventQueue : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"lehrerapp-sync-tests-attachments-{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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class EventApplierTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
|
||||
|
||||
var saved = db.Students.FindById(student.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("Anna", saved!.FirstName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Delete_EntferntDenDatensatz()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
db.Students.Insert(student);
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
|
||||
|
||||
Assert.Null(db.Students.FindById(student.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
|
||||
var exception = await Record.ExceptionAsync(() =>
|
||||
applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
|
||||
var gradeId = Guid.NewGuid();
|
||||
db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() });
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
|
||||
|
||||
Assert.Null(db.Groups.FindById(groupId));
|
||||
Assert.Null(db.Grades.FindById(gradeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var studentId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId });
|
||||
// Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index.
|
||||
var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId };
|
||||
|
||||
var exception = await Record.ExceptionAsync(() =>
|
||||
applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Single(db.Memberships.FindAll());
|
||||
}
|
||||
|
||||
// ── Loop-Prevention: der wichtigste Test in dieser Datei ────────────────────
|
||||
// Ein angewendetes Ereignis darf NIE selbst wieder ein ausgehendes Ereignis auslösen,
|
||||
// sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar).
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
db.Students.Insert(student);
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
|
||||
db.Grades.Insert(new Grade { GroupId = groupId, StudentId = Guid.NewGuid() });
|
||||
db.Exams.Insert(new Exam { GroupId = groupId });
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
// ── Fehlende Anhänge nachladen ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_DocumentationMitFehlendemAnhang_LaedtIhnUeberHttpNach()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var storageId = Guid.NewGuid().ToString("N");
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath);
|
||||
var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key);
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{ Content = new ByteArrayContent(encrypted) };
|
||||
});
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var applier = new EventApplier(db, Key, http);
|
||||
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
|
||||
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
|
||||
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.True(db.Attachments.Exists(storageId));
|
||||
using var read = db.Attachments.OpenRead(storageId);
|
||||
using var ms = new MemoryStream();
|
||||
await read.CopyToAsync(ms);
|
||||
Assert.Equal([9, 8, 7], ms.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_DocumentationMitBereitsVorhandenemAnhang_LaedtNichtErneut()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var storageId = Guid.NewGuid().ToString("N");
|
||||
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3]));
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var applier = new EventApplier(db, Key, http);
|
||||
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
|
||||
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
|
||||
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
|
||||
{
|
||||
DeviceId = "companion-1",
|
||||
DeviceType = DeviceType.Companion,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Operation = operation,
|
||||
Payload = payload is null ? "" : SyncCrypto.EncryptObject(payload, Key),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Sync.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class EventQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarkReviewed_EntferntKonfliktAusDerUnreviewedListe()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
var conflict = new ConflictEntry
|
||||
{
|
||||
LocalEvent = MakeEvent(),
|
||||
RemoteEvent = MakeEvent(),
|
||||
Resolution = "LocalWon",
|
||||
};
|
||||
temp.Queue.AddConflict(conflict);
|
||||
|
||||
temp.Queue.MarkReviewed(conflict.Id);
|
||||
|
||||
Assert.Empty(temp.Queue.GetUnreviewed());
|
||||
Assert.Equal(0, temp.Queue.ConflictCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkReviewed_UnbekannteId_TutNichtsUndWirftNicht()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
|
||||
var exception = Record.Exception(() => temp.Queue.MarkReviewed(Guid.NewGuid()));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private static SyncEvent MakeEvent() => new()
|
||||
{
|
||||
DeviceId = "desktop-1",
|
||||
DeviceType = DeviceType.Desktop,
|
||||
EntityType = "Student",
|
||||
EntityId = Guid.NewGuid().ToString(),
|
||||
Operation = "Save",
|
||||
Payload = "{}",
|
||||
};
|
||||
|
||||
private sealed class TempEventQueue : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"lehrerapp-sync-tests-eventqueue-{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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
/// <summary>Zeichnet Requests auf und beantwortet sie über eine Callback-Funktion, ohne echtes Netzwerk.</summary>
|
||||
public sealed class FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt ausstehende Datei-Anhänge (siehe <see cref="EventQueue.GetPendingAttachmentUploads"/>)
|
||||
/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit
|
||||
/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in <see cref="EventApplier"/>.
|
||||
/// </summary>
|
||||
public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
|
||||
{
|
||||
public async Task UploadPendingAsync(EventQueue queue)
|
||||
{
|
||||
foreach (var storageId in queue.GetPendingAttachmentUploads())
|
||||
{
|
||||
if (!db.Attachments.Exists(storageId))
|
||||
{
|
||||
// Lokal inzwischen wieder gelöscht (z.B. HardDelete vor dem eigentlichen Upload) -
|
||||
// nichts hochzuladen, Warteliste trotzdem bereinigen.
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var raw = db.Attachments.OpenRead(storageId);
|
||||
using var buffer = new MemoryStream();
|
||||
await raw.CopyToAsync(buffer);
|
||||
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
|
||||
|
||||
using var content = new ByteArrayContent(encrypted);
|
||||
var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using LiteDB;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wendet ein von <see cref="SyncEngine"/> empfangenes (und nicht durch einen Konflikt
|
||||
/// verlorenes) Ereignis auf die lokale Datenbank an.
|
||||
///
|
||||
/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository-
|
||||
/// Save/Delete-Methode — sonst würde <see cref="LiteDbContext.OnChange"/> erneut feuern und die
|
||||
/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong).
|
||||
/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig
|
||||
/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save
|
||||
/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt.
|
||||
///
|
||||
/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem
|
||||
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
||||
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
||||
/// </summary>
|
||||
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null)
|
||||
{
|
||||
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
||||
|
||||
public async Task ApplyAsync(SyncEvent evt)
|
||||
{
|
||||
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return;
|
||||
try
|
||||
{
|
||||
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
|
||||
handler(db, evt.Operation, evt.EntityId, json);
|
||||
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
|
||||
await DownloadMissingAttachmentsAsync(json);
|
||||
}
|
||||
catch (LiteException)
|
||||
{
|
||||
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
||||
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
|
||||
}
|
||||
}
|
||||
|
||||
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
|
||||
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
|
||||
// nachladen. Gegenstück zum Upload in AttachmentSyncer.
|
||||
private async Task DownloadMissingAttachmentsAsync(string json)
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<Documentation>(json);
|
||||
if (doc is null) return;
|
||||
foreach (var attachment in doc.Attachments)
|
||||
{
|
||||
if (db.Attachments.Exists(attachment.StorageId)) continue;
|
||||
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
|
||||
if (!resp.IsSuccessStatusCode) continue;
|
||||
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
||||
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
||||
using var stream = new MemoryStream(decrypted);
|
||||
// Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine
|
||||
// neue Id vergibt - hier muss die Original-StorageId erhalten bleiben.
|
||||
db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream);
|
||||
}
|
||||
}
|
||||
|
||||
private string Decrypt(string payloadBase64) =>
|
||||
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
|
||||
|
||||
private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json);
|
||||
|
||||
private static Dictionary<string, EntityHandler> BuildHandlers()
|
||||
{
|
||||
var handlers = new Dictionary<string, EntityHandler>();
|
||||
|
||||
void Simple<T>(Func<LiteDbContext, ILiteCollection<T>> collection) where T : class =>
|
||||
handlers[typeof(T).Name] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") collection(context).Delete(new Guid(entityId));
|
||||
else collection(context).Upsert(JsonSerializer.Deserialize<T>(json)!);
|
||||
};
|
||||
|
||||
Simple<Student>(context => context.Students);
|
||||
Simple<SeatingPlan>(context => context.SeatingPlans);
|
||||
Simple<GroupMembership>(context => context.Memberships);
|
||||
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
||||
Simple<Grade>(context => context.Grades);
|
||||
Simple<GradingScheme>(context => context.GradingSchemes);
|
||||
Simple<ReportGrade>(context => context.ReportGrades);
|
||||
Simple<Unit>(context => context.Units);
|
||||
Simple<Lesson>(context => context.Lessons);
|
||||
Simple<WorkTask>(context => context.Tasks);
|
||||
Simple<TimeEntry>(context => context.TimeEntries);
|
||||
Simple<ExamResult>(context => context.ExamResults);
|
||||
Simple<ParticipationEntry>(context => context.ParticipationEntries);
|
||||
Simple<ParticipationAspect>(context => context.ParticipationAspects);
|
||||
Simple<ParticipationSection>(context => context.ParticipationSections);
|
||||
Simple<Subject>(context => context.Subjects);
|
||||
Simple<ShorthandCode>(context => context.ShorthandCodes);
|
||||
Simple<AlternativeLessonPath>(context => context.AlternativeLessonPaths);
|
||||
Simple<TimetableSlot>(context => context.TimetableSlots);
|
||||
Simple<SchoolHoliday>(context => context.SchoolHolidays);
|
||||
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
||||
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
||||
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
||||
|
||||
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
||||
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
||||
handlers[nameof(LearningGroup)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId));
|
||||
else context.Groups.Upsert(JsonSerializer.Deserialize<LearningGroup>(json)!);
|
||||
};
|
||||
handlers[nameof(Exam)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId));
|
||||
else context.Exams.Upsert(JsonSerializer.Deserialize<Exam>(json)!);
|
||||
};
|
||||
handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId));
|
||||
else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize<ParticipationSession>(json)!);
|
||||
};
|
||||
// "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als
|
||||
// "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig.
|
||||
handlers[nameof(Documentation)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId));
|
||||
else context.Documentation.Upsert(JsonSerializer.Deserialize<Documentation>(json)!);
|
||||
};
|
||||
|
||||
return handlers;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class EventQueue : IDisposable
|
||||
private readonly ILiteCollection<SyncEvent> _queue;
|
||||
private readonly ILiteCollection<SyncMeta> _meta;
|
||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
||||
private long _currentSeq;
|
||||
|
||||
public EventQueue(string path)
|
||||
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
||||
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||
}
|
||||
@@ -56,6 +59,25 @@ public class EventQueue : IDisposable
|
||||
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
||||
public void MarkReviewed(Guid id)
|
||||
{
|
||||
var conflict = _conflicts.FindById(id);
|
||||
if (conflict is null) return;
|
||||
conflict.Reviewed = true;
|
||||
_conflicts.Update(conflict);
|
||||
}
|
||||
|
||||
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
||||
public void QueueAttachmentUpload(string storageId)
|
||||
{
|
||||
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
|
||||
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
|
||||
}
|
||||
public List<string> GetPendingAttachmentUploads() =>
|
||||
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
|
||||
public void MarkAttachmentUploaded(string storageId) =>
|
||||
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
@@ -75,3 +97,9 @@ internal class SyncMeta
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
internal class PendingAttachmentUpload
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string StorageId { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ public class SyncEngine : IDisposable
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier _applier;
|
||||
private readonly AttachmentSyncer _attachments;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
@@ -18,11 +20,13 @@ public class SyncEngine : IDisposable
|
||||
public SyncStatus Status { get; private set; } = new();
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver,
|
||||
HttpClient http, SyncConfig config)
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||
AttachmentSyncer attachments, HttpClient http, SyncConfig config)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_attachments = attachments;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_timer = new Timer(
|
||||
@@ -40,6 +44,7 @@ public class SyncEngine : IDisposable
|
||||
try
|
||||
{
|
||||
var (pushed, _) = await PushAsync();
|
||||
await _attachments.UploadPendingAsync(_queue);
|
||||
var (pulled, conflicts) = await PullAsync();
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
@@ -75,7 +80,10 @@ public class SyncEngine : IDisposable
|
||||
foreach (var evt in resp.Events)
|
||||
{
|
||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||
if (c is not null) { _queue.AddConflict(c); conflicts++; }
|
||||
if (c is null) { await _applier.ApplyAsync(evt); continue; }
|
||||
_queue.AddConflict(c);
|
||||
conflicts++;
|
||||
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
return (resp.Events.Count, conflicts);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt <see cref="LiteDbContext.OnChange"/>-Aufrufe in ausgehende Sync-Ereignisse um. Wird in
|
||||
/// AppBootstrapper an <see cref="LiteDbContext.OnChange"/> gehängt, wenn Sync konfiguriert ist —
|
||||
/// dort entsteht aus den ~27 Repository-Aufrufen genau ein verschlüsseltes Ereignis pro Aufruf.
|
||||
/// </summary>
|
||||
public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKey)
|
||||
{
|
||||
public void Publish(string entityType, string entityId, string operation, object? payload)
|
||||
{
|
||||
var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
|
||||
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted);
|
||||
|
||||
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
|
||||
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
|
||||
if (payload is Documentation doc)
|
||||
foreach (var attachment in doc.Attachments)
|
||||
queue.QueueAttachmentUpload(attachment.StorageId);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop.Tests", "
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -124,6 +126,18 @@ Global
|
||||
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1299,30 +1299,137 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
|
||||
|
||||
## 10. Sync & Server
|
||||
|
||||
Grundgerüst existiert in [LehrerApp.Sync](LehrerApp.Sync/) und [LehrerApp.Api](LehrerApp.Api/),
|
||||
ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
|
||||
Vollständig funktionsfähig: Outbound-Publishing (10.1.6), Inbound-Apply (10.1.7),
|
||||
Anhang-Sync (10.1.8), Client-UI (10.1.1–10.1.5) und Server-Härtung (10.2) sind umgesetzt.
|
||||
Nur aktiv, wenn in den Einstellungen eine Server-URL konfiguriert und ein Login erfolgt ist.
|
||||
Offen bleiben bewusst die Schlüsselverwaltungs-Punkte unter 10.3 (Gerätewechsel/-verlust) und
|
||||
die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügbar).
|
||||
|
||||
### 10.1 Client
|
||||
- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
|
||||
- [ ] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
|
||||
- [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
|
||||
- [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
|
||||
- [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
|
||||
- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
|
||||
- [x] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
|
||||
|
||||
**Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
|
||||
`LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
|
||||
AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
|
||||
bisherigen Klartext-Helfer `AppBootstrapper.LoadServerUrl`/`SaveServerUrl` und die
|
||||
unverschlüsselte `auth.token`-Datei). Neu `SyncAuthService` für den Login-HTTP-Aufruf gegen
|
||||
`/api/auth/login`. Speichern/Anmelden startet die App neu (`AppBootstrapper.
|
||||
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
|
||||
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
|
||||
Live-Re-Registrierungspfad.
|
||||
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
|
||||
|
||||
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
|
||||
& angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
|
||||
einen GET auf `/api/sync/status` mit optionalem Bearer-Token.
|
||||
- [x] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
|
||||
|
||||
**Umsetzung:** Minimale Liste im Tab "Synchronisation" (kein Feld-Diff für v1 — die
|
||||
Payloads sind clientseitig verschlüsselt, ein Diff würde ohnehin nur rohes JSON zeigen).
|
||||
Zeigt Entität, Zeitpunkt und welche Seite gewonnen hat, mit "Gesehen"-Aktion. Neu
|
||||
`EventQueue.MarkReviewed(id)` (bisher nur `AddConflict`/`GetUnreviewed`/`ConflictCount`,
|
||||
kein Weg, einen Konflikt als gesehen zu markieren).
|
||||
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
|
||||
|
||||
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`,
|
||||
`SyncStatusBar` in `MainWindow.axaml`), nur hier noch nicht abgehakt.
|
||||
- [x] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
|
||||
|
||||
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch
|
||||
nicht abgehakt.
|
||||
- [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
|
||||
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
|
||||
|
||||
**Umsetzung:** `LiteDbContext.OnChange`-Hook (Sync-agnostisch, kein Verweis auf
|
||||
`LehrerApp.Sync` aus `LehrerApp.Data`) — alle 27 Repositories rufen ihn nach Save/Delete auf,
|
||||
Kaskaden (`GroupRepository.Delete` u.a.) und Batch-Methoden (`SaveMany`/`DeleteBySession`)
|
||||
feuern genau ein Ereignis pro betroffener Entität statt pro Collection-Zugriff. In
|
||||
`AppBootstrapper` an `SyncEventPublisher.Publish` gehängt, das den Hook in ein
|
||||
AES-verschlüsseltes `EventQueue.Enqueue` übersetzt.
|
||||
- [x] **10.1.7** Eingehende Sync-Ereignisse tatsächlich auf die lokale Datenbank anwenden.
|
||||
|
||||
Bisher komplett fehlender, in dieser Checkliste nicht erfasster Baustein: selbst mit 10.1.6
|
||||
hätte `SyncEngine.PullAsync` empfangene Ereignisse nur zur Konflikterkennung genutzt, nie in
|
||||
die lokale LiteDB geschrieben — ankommende Änderungen von anderen Geräten wären nirgends
|
||||
sichtbar geworden.
|
||||
|
||||
**Umsetzung:** Neu `LehrerApp.Sync/EventApplier.cs` — entschlüsselt, dispatcht über eine
|
||||
explizite `EntityType`-Tabelle, schreibt **immer direkt auf die rohe LiteDB-Collection**,
|
||||
nie über eine Repository-Save/Delete-Methode (sonst würde der 10.1.6-Hook die gerade
|
||||
angewendete Änderung als neues ausgehendes Ereignis re-enqueuen — Sync-Ping-Pong). Ein
|
||||
gemeinsames Suppress-Flag wurde geprüft und verworfen (Timer-Thread vs. UI-Thread — ein Flag
|
||||
könnte einen echten Nutzer-Save währenddessen verschlucken); der direkte Collection-Zugriff
|
||||
ist zustandslos und dadurch korrekt. Kaskaden-Fälle nutzen dieselben internen
|
||||
`LiteDbContext`-Hilfsmethoden wie die Repositories. Mit dediziertem Loop-Prevention-Test
|
||||
abgesichert (`EventApplierTests`).
|
||||
|
||||
**Bekannte v1-Einschränkung:** weiche Geschäftsregeln (`ArchivedGroupWriteGuard`,
|
||||
Namens-Eindeutigkeit bei Aspekten u.ä.) werden auf diesem Pfad nicht geprüft — nur harte
|
||||
LiteDB-Unique-Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
||||
Für Einzel-/Wenig-Geräte-Nutzung akzeptiert, siehe 10.3.4.
|
||||
- [x] **10.1.8** Datei-Anhänge (Dokumentation) über den laufenden Sync mitschicken.
|
||||
|
||||
**Umsetzung:** Eigener, unverschlüsselt im JSON-Ereigniskanal nicht mitgeführter Binärkanal
|
||||
(würde ihn für Fotos/Scans stark aufblähen) — neue Endpunkte
|
||||
`POST/GET /api/sync/attachments/{storageId}` in `LehrerApp.Api`, neue
|
||||
`EventQueue`-Warteliste für ausstehende Uploads, `AttachmentSyncer` (Upload, in
|
||||
`SyncEngine.SyncNowAsync` nach dem Event-Push) und `EventApplier` (Download fehlender
|
||||
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
|
||||
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
|
||||
das immer eine neue Id vergäbe).
|
||||
|
||||
### 10.2 Server
|
||||
- [ ] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
||||
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
||||
([Endpoints.cs](LehrerApp.Api/Endpoints/Endpoints.cs)).
|
||||
- [ ] **10.2.2** Rate Limiting und Request-Größenbegrenzung.
|
||||
- [ ] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien.
|
||||
|
||||
**Umsetzung:** `/api/auth/login` und `/api/auth/register` akzeptierten zuvor jeden
|
||||
beliebigen Nutzernamen/Passwort und stellten ein gültiges 30-Tage-JWT aus (unadressierte
|
||||
`// TODO`-Kommentare im Code) — konkrete, ausnutzbare Lücke bei echtem Deployment. Neu
|
||||
`PasswordHasher` (PBKDF2, Salt pro Nutzer — kein neues NuGet-Paket, gleiche BCL-Technik wie
|
||||
`SyncCrypto`) und `UserStore` (LiteDB-Collection `users`). `/api/auth/register` ersatzlos
|
||||
entfernt (kein offener Registrierungs-Endpunkt für ein Einzel-/Familien-Deployment); neue
|
||||
Nutzer werden per CLI angelegt (`dotnet LehrerApp.Api.dll create-user <name>`, dokumentiert
|
||||
in `docker/README.md`), damit keine zusätzliche unauthentifizierte Angriffsfläche entsteht.
|
||||
- [x] **10.2.2** Rate Limiting und Request-Größenbegrenzung.
|
||||
|
||||
**Umsetzung:** ASP.NET Cores eingebautes `Microsoft.AspNetCore.RateLimiting` (keine neue
|
||||
Paketabhängigkeit). `/api/auth/login` speziell auf 5 Versuche/Minute begrenzt
|
||||
(Brute-Force-Schutz), alle Endpunkte zusätzlich global auf 120 Anfragen/Minute je IP. Kestrel
|
||||
`MaxRequestBodySize` auf 15 MB gedeckelt (Anhänge sind clientseitig ohnehin auf 10 MB
|
||||
begrenzt, siehe `IAttachmentStorage.MaxSizeBytes`).
|
||||
- [x] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien.
|
||||
|
||||
**Umsetzung:** `docker/backup.sh` — Tar-Archiv von `./data` (Ereignis-Logs, Snapshots,
|
||||
Anhänge, Nutzer), räumt Archive älter als 30 Tage auf, läuft direkt auf dem Host (kein
|
||||
Container-Zugriff nötig), dokumentiert samt Cron-Beispiel in `docker/README.md`.
|
||||
- [ ] **10.2.4** Docker-Setup in [docker/](docker/) verifizieren und dokumentieren.
|
||||
|
||||
**Teilweise:** `docker/README.md` um `create-user`-Flow, Backup und Rate-Limits ergänzt.
|
||||
Ein tatsächlicher `docker compose up`-Durchlauf konnte in dieser Umgebung nicht verifiziert
|
||||
werden (kein Docker verfügbar) — steht vor dem ersten echten Deployment noch aus.
|
||||
|
||||
### 10.3 Verschlüsselung
|
||||
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
|
||||
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
|
||||
- [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
|
||||
personenbezogene Daten dürfen das nicht.
|
||||
- [ ] **10.3.4** Bekannte v1-Einschränkung aus 10.1.7: weiche Geschäftsregeln greifen beim Anwenden
|
||||
eingehender Sync-Ereignisse nicht, nur harte LiteDB-Unique-Constraints. Bei mehreren eigenen
|
||||
Geräten in Randfällen möglich, dass sich Datenstände leicht unterscheiden. Für v1 bewusst
|
||||
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
|
||||
dabei erneut ein Sync-Ereignis auszulösen.
|
||||
|
||||
**Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests,
|
||||
inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,
|
||||
`AttachmentStoreTests`). Zusätzlich Live-Rauchtest gegen einen tatsächlich laufenden
|
||||
`LehrerApp.Api`-Prozess (nicht gemockt): `create-user`-CLI, Login (richtig/falsch), Zugriffsschutz
|
||||
auf `/api/sync/status`, entferntes `/api/auth/register` (404), Push/Pull-Roundtrip zwischen zwei
|
||||
Geräte-Ids, Anhang-Upload/Download byteidentisch, Rate-Limit auf `/api/auth/login` löst nach 5
|
||||
Versuchen tatsächlich 429 aus. **Nicht verifizierbar in dieser Umgebung:** zwei echte
|
||||
Desktop-Instanzen gegeneinander synchronisieren (kein Mehrfach-AppData-Mechanismus vorhanden, GUI
|
||||
nicht headless steuerbar) und ein echter `docker compose up`-Durchlauf (kein Docker verfügbar) —
|
||||
beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuholen.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# LehrerApp.Api – Deployment per Docker
|
||||
|
||||
## Starten
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
JWT_SECRET=<zufälliger-langer-string> docker compose up -d
|
||||
```
|
||||
|
||||
`./data` (relativ zu `docker/`) wird als Volume gemountet und enthält alle Server-Daten
|
||||
(Ereignis-Logs, Snapshots, Nutzer) – bei Neustarts/Updates bleibt es erhalten.
|
||||
|
||||
## Nutzer anlegen
|
||||
|
||||
Es gibt keine offene Registrierung (`/api/auth/register`). Neue Nutzer werden per CLI im
|
||||
laufenden Container angelegt:
|
||||
|
||||
```bash
|
||||
docker compose exec api dotnet LehrerApp.Api.dll create-user <benutzername>
|
||||
```
|
||||
|
||||
Fragt interaktiv nach einem Passwort (mind. 12 Zeichen). Alternativ nicht-interaktiv, z. B. aus
|
||||
einem Skript:
|
||||
|
||||
```bash
|
||||
docker compose exec api dotnet LehrerApp.Api.dll create-user <benutzername> --password "<passwort>"
|
||||
```
|
||||
|
||||
Der Befehl beendet sich danach sofort wieder, ohne den API-Dienst zu starten – für den
|
||||
eigentlichen Serverbetrieb läuft `docker compose up` unverändert weiter.
|
||||
|
||||
## Backup
|
||||
|
||||
`backup.sh` sichert `./data` (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als komprimiertes
|
||||
Tar-Archiv unter `docker/backups/` und räumt Archive älter als 30 Tage automatisch auf. Läuft
|
||||
direkt auf dem Host (kein Container-Zugriff nötig, `./data` liegt dort per Bind-Mount ohnehin):
|
||||
|
||||
```bash
|
||||
./docker/backup.sh
|
||||
```
|
||||
|
||||
Für regelmäßige Sicherung z. B. per Cron, einmal täglich nachts:
|
||||
|
||||
```bash
|
||||
0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
`/api/auth/login` ist auf 5 Versuche pro Minute begrenzt (Brute-Force-Schutz). Alle übrigen
|
||||
Endpunkte sind zusätzlich global auf 120 Anfragen pro Minute je IP begrenzt. Anhänge sind auf
|
||||
15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig
|
||||
zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`).
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Sichert das komplette ./data-Verzeichnis (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als
|
||||
# komprimiertes Tar-Archiv. Läuft außerhalb des Containers direkt auf dem Host, da ./data per
|
||||
# Bind-Mount ohnehin dort liegt (siehe docker-compose.yml) — kein Zugriff auf den Container nötig.
|
||||
#
|
||||
# Aufruf z.B. per Cron:
|
||||
# 0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DATA_DIR="$SCRIPT_DIR/data"
|
||||
BACKUP_DIR="$SCRIPT_DIR/backups"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
KEEP_DAYS=30
|
||||
|
||||
if [ ! -d "$DATA_DIR" ]; then
|
||||
echo "Kein Datenverzeichnis unter $DATA_DIR gefunden - nichts zu sichern." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
tar -czf "$BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data
|
||||
|
||||
# Alte Backups jenseits von KEEP_DAYS aufräumen, damit das Verzeichnis nicht unbegrenzt wächst.
|
||||
find "$BACKUP_DIR" -name 'lehrerapp-data-*.tar.gz' -mtime "+$KEEP_DAYS" -delete
|
||||
|
||||
echo "Backup erstellt: $BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz"
|
||||
Reference in New Issue
Block a user