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.Core.Models;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace LehrerApp.Api;
|
namespace LehrerApp.Api;
|
||||||
@@ -14,21 +15,14 @@ public static class Endpoints
|
|||||||
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app, string secret)
|
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))
|
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
||||||
return Results.Unauthorized();
|
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 });
|
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
|
||||||
});
|
}).RequireRateLimiting("login");
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync ──────────────────────────────────────────────────────────────────
|
// ── 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) ─────────────────────────────────────────────
|
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
|
||||||
|
|
||||||
public static void MapSnapshotEndpoints(this WebApplication app)
|
public static void MapSnapshotEndpoints(this WebApplication app)
|
||||||
@@ -130,4 +148,3 @@ public static class Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
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.Text;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using LehrerApp.Api;
|
using LehrerApp.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -9,8 +11,16 @@ builder.WebHost.UseKestrel(o =>
|
|||||||
{
|
{
|
||||||
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
||||||
o.ListenAnyIP(port);
|
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"]
|
var secret = builder.Configuration["JWT_SECRET"]
|
||||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
||||||
|
|
||||||
@@ -24,7 +34,32 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
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<EventStore>(_ => new EventStore(data));
|
||||||
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||||
@@ -32,11 +67,14 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
|
|||||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapAuthEndpoints(secret);
|
app.MapAuthEndpoints(secret);
|
||||||
app.MapSyncEndpoints();
|
app.MapSyncEndpoints();
|
||||||
|
app.MapAttachmentEndpoints();
|
||||||
app.MapSnapshotEndpoints();
|
app.MapSnapshotEndpoints();
|
||||||
app.MapReadableSnapshotEndpoints();
|
app.MapReadableSnapshotEndpoints();
|
||||||
app.MapPlainSyncEndpoints();
|
app.MapPlainSyncEndpoints();
|
||||||
app.Run();
|
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;
|
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>
|
/// <summary>
|
||||||
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -67,6 +71,117 @@ public class LiteDbContext : IDisposable
|
|||||||
|
|
||||||
public int SchemaVersion => ReadSchemaVersion();
|
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)
|
internal void ExecuteInTransaction(Action action)
|
||||||
{
|
{
|
||||||
_db.BeginTrans();
|
_db.BeginTrans();
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
db.ReportGrades.Count(g => g.StudentId == studentId),
|
db.ReportGrades.Count(g => g.StudentId == studentId),
|
||||||
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
||||||
db.Documentation.Count(d => d.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)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
var references = GetReferenceSummary(id);
|
var references = GetReferenceSummary(id);
|
||||||
@@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
||||||
db.Students.Delete(id);
|
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.");
|
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
||||||
g.UpdatedAt = DateTime.UtcNow;
|
g.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Groups.Upsert(g);
|
db.Groups.Upsert(g);
|
||||||
|
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
||||||
db.ExecuteInTransaction(() =>
|
db.CascadeDeleteGroup(id);
|
||||||
{
|
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +134,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
|||||||
|
|
||||||
plan.UpdatedAt = DateTime.UtcNow;
|
plan.UpdatedAt = DateTime.UtcNow;
|
||||||
db.SeatingPlans.Upsert(plan);
|
db.SeatingPlans.Upsert(plan);
|
||||||
|
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
@@ -203,6 +142,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
|
|||||||
if (db.SeatingPlans.FindById(id) is { } plan)
|
if (db.SeatingPlans.FindById(id) is { } plan)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||||
db.SeatingPlans.Delete(id);
|
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)
|
if (existing is not null && existing.Id != membership.Id)
|
||||||
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
||||||
db.Memberships.Upsert(membership);
|
db.Memberships.Upsert(membership);
|
||||||
|
db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Memberships.FindById(id) is { } membership)
|
if (db.Memberships.FindById(id) is { } membership)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
||||||
db.Memberships.Delete(id);
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
|
||||||
e.UpdatedAt = DateTime.UtcNow;
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Exams.Upsert(e);
|
db.Exams.Upsert(e);
|
||||||
|
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Exams.FindById(id) is { } exam)
|
if (db.Exams.FindById(id) is { } exam)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||||
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
|
db.CascadeDeleteExam(id);
|
||||||
db.ExamResults.Delete(result.Id);
|
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
|
||||||
db.Exams.Delete(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +208,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
|
||||||
r.UpdatedAt = DateTime.UtcNow;
|
r.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ExamResults.Upsert(r);
|
db.ExamResults.Upsert(r);
|
||||||
|
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
|
||||||
}
|
}
|
||||||
public void SaveMany(List<ExamResult> results)
|
public void SaveMany(List<ExamResult> results)
|
||||||
{
|
{
|
||||||
@@ -275,6 +218,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var r in results) r.UpdatedAt = now;
|
foreach (var r in results) r.UpdatedAt = now;
|
||||||
db.ExamResults.Upsert(results);
|
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) =>
|
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
|
||||||
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
|
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
|
||||||
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
|
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
|
||||||
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); }
|
public void Save(GradingKeyTemplate t)
|
||||||
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id);
|
{
|
||||||
|
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
|
public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||||
@@ -299,12 +253,14 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
|
|||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
|
||||||
db.Grades.Upsert(g);
|
db.Grades.Upsert(g);
|
||||||
|
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Grades.FindById(id) is { } grade)
|
if (db.Grades.FindById(id) is { } grade)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||||
db.Grades.Delete(id);
|
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);
|
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.GradingSchemes.Upsert(s);
|
db.GradingSchemes.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
|
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
db.GradingSchemes.Delete(id);
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
|
||||||
r.UpdatedAt = DateTime.UtcNow;
|
r.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ReportGrades.Upsert(r);
|
db.ReportGrades.Upsert(r);
|
||||||
|
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ReportGrades.FindById(id) is { } grade)
|
if (db.ReportGrades.FindById(id) is { } grade)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
|
||||||
db.ReportGrades.Delete(id);
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||||
u.UpdatedAt = DateTime.UtcNow;
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Units.Upsert(u);
|
db.Units.Upsert(u);
|
||||||
|
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Units.FindById(id) is { } unit)
|
if (db.Units.FindById(id) is { } unit)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
|
||||||
db.Units.Delete(id);
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
|
||||||
l.UpdatedAt = DateTime.UtcNow;
|
l.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Lessons.Upsert(l);
|
db.Lessons.Upsert(l);
|
||||||
|
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Lessons.FindById(id) is { } lesson)
|
if (db.Lessons.FindById(id) is { } lesson)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
|
||||||
db.Lessons.Delete(id);
|
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();
|
.OrderByDescending(d => d.Date).ToList();
|
||||||
public List<Documentation> GetAll() =>
|
public List<Documentation> GetAll() =>
|
||||||
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
|
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)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
var doc = db.Documentation.FindById(id);
|
var doc = db.Documentation.FindById(id);
|
||||||
@@ -405,13 +374,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
|||||||
doc.IsDeleted = true;
|
doc.IsDeleted = true;
|
||||||
doc.DeletedAt = DateTime.UtcNow;
|
doc.DeletedAt = DateTime.UtcNow;
|
||||||
db.Documentation.Update(doc);
|
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)
|
public void HardDelete(Guid id)
|
||||||
{
|
{
|
||||||
var doc = db.Documentation.FindById(id);
|
db.CascadeHardDeleteDocumentation(id);
|
||||||
if (doc is not null)
|
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
|
||||||
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
|
|
||||||
db.Documentation.Delete(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,8 +391,17 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
|||||||
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
||||||
public List<WorkTask> GetAll() =>
|
public List<WorkTask> GetAll() =>
|
||||||
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
|
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 Save(WorkTask t)
|
||||||
public void Delete(Guid id) => db.Tasks.Delete(id);
|
{
|
||||||
|
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
|
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();
|
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
|
||||||
public List<TimeEntry> GetByTask(Guid id) =>
|
public List<TimeEntry> GetByTask(Guid id) =>
|
||||||
db.TimeEntries.Find(e => e.TaskId == id).ToList();
|
db.TimeEntries.Find(e => e.TaskId == id).ToList();
|
||||||
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e);
|
public void Save(TimeEntry e)
|
||||||
public void Delete(Guid id) => db.TimeEntries.Delete(id);
|
{
|
||||||
|
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
|
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
|
||||||
@@ -447,14 +434,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationSessions.Upsert(s);
|
db.ParticipationSessions.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSessions.FindById(id) is { } session)
|
if (db.ParticipationSessions.FindById(id) is { } session)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
db.ParticipationSessions.Delete(id);
|
db.CascadeDeleteParticipationSession(id);
|
||||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +459,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
|||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
e.UpdatedAt = DateTime.UtcNow;
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationEntries.Upsert(e);
|
db.ParticipationEntries.Upsert(e);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void SaveMany(List<ParticipationEntry> entries)
|
public void SaveMany(List<ParticipationEntry> entries)
|
||||||
{
|
{
|
||||||
@@ -481,13 +469,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var e in entries) e.UpdatedAt = now;
|
foreach (var e in entries) e.UpdatedAt = now;
|
||||||
db.ParticipationEntries.Upsert(entries);
|
db.ParticipationEntries.Upsert(entries);
|
||||||
|
foreach (var e in entries)
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
|
||||||
}
|
}
|
||||||
public void DeleteBySession(Guid sessionId)
|
public void DeleteBySession(Guid sessionId)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSessions.FindById(sessionId) is { } session)
|
if (db.ParticipationSessions.FindById(sessionId) is { } session)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
||||||
|
{
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
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.");
|
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
|
||||||
a.UpdatedAt = DateTime.UtcNow;
|
a.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ParticipationAspects.Upsert(a);
|
db.ParticipationAspects.Upsert(a);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
|
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
|
||||||
db.ParticipationAspects.Delete(id);
|
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);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
db.ParticipationSections.Upsert(s);
|
db.ParticipationSections.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.ParticipationSections.FindById(id) is { } section)
|
if (db.ParticipationSections.FindById(id) is { } section)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
|
||||||
db.ParticipationSections.Delete(id);
|
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.");
|
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Subjects.Upsert(s);
|
db.Subjects.Upsert(s);
|
||||||
|
db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == 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.");
|
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
|
||||||
db.Subjects.Delete(id);
|
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.");
|
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
|
||||||
c.UpdatedAt = DateTime.UtcNow;
|
c.UpdatedAt = DateTime.UtcNow;
|
||||||
db.ShorthandCodes.Upsert(c);
|
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
|
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.");
|
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
|
||||||
p.UpdatedAt = DateTime.UtcNow;
|
p.UpdatedAt = DateTime.UtcNow;
|
||||||
db.AlternativeLessonPaths.Upsert(p);
|
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
|
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)
|
if (occupied is not null && occupied.Id != slot.Id)
|
||||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||||
db.TimetableSlots.Upsert(slot);
|
db.TimetableSlots.Upsert(slot);
|
||||||
|
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
|
||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
if (db.TimetableSlots.FindById(id) is { } slot)
|
if (db.TimetableSlots.FindById(id) is { } slot)
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
||||||
db.TimetableSlots.Delete(id);
|
db.TimetableSlots.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
||||||
{
|
{
|
||||||
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
||||||
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
|
public void Save(SchoolHoliday holiday)
|
||||||
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
|
{
|
||||||
|
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
|
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)
|
if (occupied is not null && occupied.Id != duty.Id)
|
||||||
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
|
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
|
||||||
db.SupervisionDuties.Upsert(duty);
|
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
|
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> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
|
||||||
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
|
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
|
||||||
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
|
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
|
||||||
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry);
|
public void Save(SubstitutionEntry entry)
|
||||||
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id);
|
{
|
||||||
|
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
|
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
@@ -667,14 +699,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
.OrderBy(d => d.SortOrder)
|
.OrderBy(d => d.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||||
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); }
|
public void Save(CompetencyDomain d)
|
||||||
public void Delete(Guid id) => db.CompetencyDomains.Delete(id);
|
{
|
||||||
|
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)
|
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
|
||||||
{
|
{
|
||||||
foreach (var d in db.CompetencyDomains
|
foreach (var d in db.CompetencyDomains
|
||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.ToList())
|
.ToList())
|
||||||
|
{
|
||||||
db.CompetencyDomains.Delete(d.Id);
|
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)
|
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
|
foreach (var domain in db.CompetencyDomains
|
||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
|
||||||
|
{
|
||||||
db.CompetencyDomains.Delete(domain.Id);
|
db.CompetencyDomains.Delete(domain.Id);
|
||||||
|
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var domain in domains)
|
foreach (var domain in domains)
|
||||||
{
|
{
|
||||||
@@ -690,6 +737,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
domain.GradeLevel = gradeLevel;
|
domain.GradeLevel = gradeLevel;
|
||||||
domain.UpdatedAt = now;
|
domain.UpdatedAt = now;
|
||||||
db.CompetencyDomains.Upsert(domain);
|
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.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
@@ -26,6 +27,26 @@ public static class TestSupport
|
|||||||
public static AiPlanningService BuildAiPlanningService() => new(
|
public static AiPlanningService BuildAiPlanningService() => new(
|
||||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
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
|
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
using System.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests
|
|||||||
{
|
{
|
||||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||||
FakeSupervisionDuties? supervisionDuties = 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,
|
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||||
@@ -26,7 +30,48 @@ public sealed class SettingsViewModelTests
|
|||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
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]
|
[Fact]
|
||||||
@@ -102,7 +147,8 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
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";
|
vm.SelectedStateName = "Bayern";
|
||||||
|
|
||||||
@@ -124,7 +170,8 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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].StartText = "08:00";
|
||||||
vm.PeriodTimes[0].EndText = "08:45";
|
vm.PeriodTimes[0].EndText = "08:45";
|
||||||
@@ -150,7 +197,8 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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].StartText = "08:45";
|
||||||
vm.PeriodTimes[0].EndText = "08:00";
|
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>();
|
services.AddSingleton<AiPlanningService>();
|
||||||
|
|
||||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
// ── 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(_ => new EventQueue(queuePath));
|
||||||
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||||
services.AddSingleton<byte[]>(_ =>
|
services.AddSingleton<byte[]>(_ =>
|
||||||
@@ -173,15 +177,26 @@ public static class AppBootstrapper
|
|||||||
return key;
|
return key;
|
||||||
});
|
});
|
||||||
|
|
||||||
var serverUrl = LoadServerUrl(appData);
|
var serverUrl = syncSettings.ServerUrl;
|
||||||
var deviceId = LoadOrCreateDeviceId(appData);
|
var deviceId = LoadOrCreateDeviceId(appData);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(serverUrl))
|
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(
|
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
|
||||||
sp.GetRequiredService<EventQueue>(),
|
sp.GetRequiredService<EventQueue>(),
|
||||||
sp.GetRequiredService<ConflictResolver>(),
|
sp.GetRequiredService<ConflictResolver>(),
|
||||||
BuildHttp(serverUrl, appData),
|
sp.GetRequiredService<EventApplier>(),
|
||||||
|
sp.GetRequiredService<AttachmentSyncer>(),
|
||||||
|
BuildHttp(serverUrl, syncSettings),
|
||||||
new SyncConfig
|
new SyncConfig
|
||||||
{
|
{
|
||||||
ServerUrl = serverUrl,
|
ServerUrl = serverUrl,
|
||||||
@@ -191,7 +206,7 @@ public static class AppBootstrapper
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
|
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
|
||||||
BuildHttp(serverUrl, appData),
|
BuildHttp(serverUrl, syncSettings),
|
||||||
sp.GetRequiredService<LiteDbContext>(),
|
sp.GetRequiredService<LiteDbContext>(),
|
||||||
sp.GetRequiredService<byte[]>(),
|
sp.GetRequiredService<byte[]>(),
|
||||||
DeviceType.Desktop, DbPath, keyPath));
|
DeviceType.Desktop, DbPath, keyPath));
|
||||||
@@ -223,30 +238,29 @@ public static class AppBootstrapper
|
|||||||
services.AddTransient<AddGroupDialogViewModel>();
|
services.AddTransient<AddGroupDialogViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
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 ─────────────────────────────────────────────────────────
|
// ── 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 http = new HttpClient { BaseAddress = new Uri(url) };
|
||||||
var tokenPath = Path.Combine(appData, "auth.token");
|
var token = syncSettings.GetToken();
|
||||||
if (File.Exists(tokenPath))
|
if (token is not null)
|
||||||
http.DefaultRequestHeaders.Authorization =
|
http.DefaultRequestHeaders.Authorization =
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
"Bearer", File.ReadAllText(tokenPath).Trim());
|
|
||||||
return http;
|
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)
|
private static string LoadOrCreateDeviceId(string appData)
|
||||||
{
|
{
|
||||||
var p = Path.Combine(appData, "device.id");
|
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.Data;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -164,6 +165,17 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
[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 ───────────────────────────────────────────────────────────
|
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||||
@@ -172,6 +184,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||||
private readonly AiSettingsService _aiSettings;
|
private readonly AiSettingsService _aiSettings;
|
||||||
private readonly AiPlanningService _aiPlanning;
|
private readonly AiPlanningService _aiPlanning;
|
||||||
|
private readonly SyncSettingsService _syncSettings;
|
||||||
|
private readonly SyncAuthService _syncAuth;
|
||||||
|
private readonly EventQueue _eventQueue;
|
||||||
private readonly CompetencyCatalogImportService _catalogImport;
|
private readonly CompetencyCatalogImportService _catalogImport;
|
||||||
|
|
||||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||||
@@ -182,7 +197,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||||
AiSettingsService aiSettings, AiPlanningService aiPlanning)
|
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||||
|
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_domainRepo = domainRepo;
|
_domainRepo = domainRepo;
|
||||||
@@ -204,6 +220,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_letterTemplates = letterTemplates;
|
_letterTemplates = letterTemplates;
|
||||||
_aiSettings = aiSettings;
|
_aiSettings = aiSettings;
|
||||||
_aiPlanning = aiPlanning;
|
_aiPlanning = aiPlanning;
|
||||||
|
_syncSettings = syncSettings;
|
||||||
|
_syncAuth = syncAuth;
|
||||||
|
_eventQueue = eventQueue;
|
||||||
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadShorthandCodes();
|
LoadShorthandCodes();
|
||||||
@@ -221,6 +240,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadSupervisionDuties();
|
LoadSupervisionDuties();
|
||||||
LoadLetterTemplates();
|
LoadLetterTemplates();
|
||||||
LoadAiSettings();
|
LoadAiSettings();
|
||||||
|
LoadSyncSettings();
|
||||||
|
LoadSyncConflicts();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||||
@@ -362,6 +383,86 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
AiBalanceDisplay = "";
|
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 ─────────────────────────────────────
|
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||||
|
|
||||||
private void LoadPeriodTimes()
|
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 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 class SchoolHolidayItem(SchoolHoliday h)
|
||||||
{
|
{
|
||||||
public Guid Id { get; } = h.Id;
|
public Guid Id { get; } = h.Id;
|
||||||
|
|||||||
@@ -759,6 +759,83 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</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>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</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<SyncEvent> _queue;
|
||||||
private readonly ILiteCollection<SyncMeta> _meta;
|
private readonly ILiteCollection<SyncMeta> _meta;
|
||||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||||
|
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
||||||
private long _currentSeq;
|
private long _currentSeq;
|
||||||
|
|
||||||
public EventQueue(string path)
|
public EventQueue(string path)
|
||||||
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
|
|||||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||||
|
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
||||||
|
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
||||||
_queue.EnsureIndex(x => x.SequenceNr);
|
_queue.EnsureIndex(x => x.SequenceNr);
|
||||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,25 @@ public class EventQueue : IDisposable
|
|||||||
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||||
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||||
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
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();
|
public void Dispose() => _db.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,3 +97,9 @@ internal class SyncMeta
|
|||||||
public long Value { get; set; }
|
public long Value { get; set; }
|
||||||
public DateTime? Timestamp { 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 EventQueue _queue;
|
||||||
private readonly ConflictResolver _resolver;
|
private readonly ConflictResolver _resolver;
|
||||||
|
private readonly EventApplier _applier;
|
||||||
|
private readonly AttachmentSyncer _attachments;
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly SyncConfig _config;
|
private readonly SyncConfig _config;
|
||||||
private readonly Timer _timer;
|
private readonly Timer _timer;
|
||||||
@@ -18,11 +20,13 @@ public class SyncEngine : IDisposable
|
|||||||
public SyncStatus Status { get; private set; } = new();
|
public SyncStatus Status { get; private set; } = new();
|
||||||
public event Action<SyncStatus>? StatusChanged;
|
public event Action<SyncStatus>? StatusChanged;
|
||||||
|
|
||||||
public SyncEngine(EventQueue queue, ConflictResolver resolver,
|
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||||
HttpClient http, SyncConfig config)
|
AttachmentSyncer attachments, HttpClient http, SyncConfig config)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_resolver = resolver;
|
_resolver = resolver;
|
||||||
|
_applier = applier;
|
||||||
|
_attachments = attachments;
|
||||||
_http = http;
|
_http = http;
|
||||||
_config = config;
|
_config = config;
|
||||||
_timer = new Timer(
|
_timer = new Timer(
|
||||||
@@ -40,6 +44,7 @@ public class SyncEngine : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (pushed, _) = await PushAsync();
|
var (pushed, _) = await PushAsync();
|
||||||
|
await _attachments.UploadPendingAsync(_queue);
|
||||||
var (pulled, conflicts) = await PullAsync();
|
var (pulled, conflicts) = await PullAsync();
|
||||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||||
SetState(SyncState.Idle);
|
SetState(SyncState.Idle);
|
||||||
@@ -75,7 +80,10 @@ public class SyncEngine : IDisposable
|
|||||||
foreach (var evt in resp.Events)
|
foreach (var evt in resp.Events)
|
||||||
{
|
{
|
||||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
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);
|
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||||
return (resp.Events.Count, conflicts);
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1299,30 +1299,137 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
|
|||||||
|
|
||||||
## 10. Sync & Server
|
## 10. Sync & Server
|
||||||
|
|
||||||
Grundgerüst existiert in [LehrerApp.Sync](LehrerApp.Sync/) und [LehrerApp.Api](LehrerApp.Api/),
|
Vollständig funktionsfähig: Outbound-Publishing (10.1.6), Inbound-Apply (10.1.7),
|
||||||
ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
|
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 Client
|
||||||
- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
|
- [x] **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.
|
**Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
|
||||||
- [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
|
`LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
|
||||||
- [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
|
AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
|
||||||
- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
|
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.
|
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 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)).
|
([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.
|
- [ ] **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 Verschlüsselung
|
||||||
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
|
- [ ] **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.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
|
||||||
- [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
|
- [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
|
||||||
personenbezogene Daten dürfen das nicht.
|
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