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>
220 lines
9.4 KiB
C#
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,
|
|
};
|
|
}
|