Files
LehrerApp/LehrerApp.Sync.Tests/EventApplierTests.cs
adminandClaude Sonnet 5 038337997f feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent
Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:13:14 +02:00

220 lines
9.4 KiB
C#

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);
}
/// Regressionstest für die Verallgemeinerung von "nur Documentation" auf
/// <see cref="IHasAttachments"/> — Anhänge an einer <see cref="Lesson"/> (Material,
/// Gefährdungsbeurteilung) müssen genauso nachgeladen werden.
[Fact]
public async Task ApplyAsync_LessonMitFehlendemAnhang_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 lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Topic = "Brechung" };
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "gbu.pdf", SizeBytes = 3 });
await applier.ApplyAsync(MakeEvent(nameof(Lesson), lesson.Id.ToString(), "Save", lesson));
Assert.Single(handler.Requests);
Assert.True(db.Attachments.Exists(storageId));
}
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,
};
}