Files
LehrerApp/LehrerApp.Sync.Tests/EventApplierTests.cs
T
adminandClaude Opus 5.5 7d8deaf79c
CI / build-and-test (push) Canceled after 0s
Zurückstellen von Dashboard-Warnungen synchronisieren
Eine auf einem Gerät zurückgestellte Warnung stand auf den anderen weiter
rot oben, weil DashboardWarningSnooze bewusst lokal gehalten war. Save löst
jetzt db.OnChange aus, EventApplier übernimmt den Typ. Die Id wird aus
(StudentId, WarningTag) abgeleitet, damit zwei Geräte denselben Datensatz
beschreiben statt am Unique-Index ux_student_tag zu kollidieren;
Schema-Migration 7 schlüsselt bestehende lokale Einträge um.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:03:44 +02:00

307 lines
13 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_UntisHubJobStateSave_SchreibtEntitaetDirektInDieCollection()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
await applier.ApplyAsync(MakeEvent(nameof(UntisHubJobState), state.Id.ToString(), "Save", state));
var saved = db.UntisHubJobStates.FindById(state.Id);
Assert.NotNull(saved);
Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind);
}
[Fact]
public async Task ApplyAsync_DashboardWarningSnoozeSave_UeberschreibtLokalenEintragFuerDasselbePaar()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var studentId = Guid.NewGuid();
// Beide Geräte stellen dieselbe Warnung unabhängig zurück — dank IdFor dieselbe Id, also
// kein Konflikt am Unique-Index ux_student_tag, sondern ein schlichtes Überschreiben.
db.DashboardWarningSnoozes.Insert(new DashboardWarningSnooze
{
Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"),
StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 5),
});
var remote = new DashboardWarningSnooze
{
Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"),
StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 26),
};
await applier.ApplyAsync(MakeEvent(nameof(DashboardWarningSnooze), remote.Id.ToString(), "Save", remote));
var saved = Assert.Single(db.DashboardWarningSnoozes.FindAll());
Assert.Equal(new DateOnly(2026, 10, 26), saved.SnoozedUntil);
}
[Fact]
public async Task ApplyAsync_CompetencyDomainSave_UebernimmtVerschachtelteKompetenzen()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var domain = new CompetencyDomain
{
SubjectId = Guid.NewGuid(), GradeLevel = 10, VersionId = Guid.NewGuid(),
Name = "Erkenntnisgewinnung",
Items = [new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }],
};
await applier.ApplyAsync(MakeEvent(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain));
var saved = db.CompetencyDomains.FindById(domain.Id);
Assert.NotNull(saved);
Assert.Equal("EG1", Assert.Single(saved!.Items).Code);
}
[Fact]
public async Task ApplyAsync_CompetencyDomainSave_BereinigtDoppelteMigrierteStandardversionen()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var subjectId = Guid.NewGuid();
var emptyVersion = new CompetencyCatalogVersion
{
SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true,
};
var catalogVersion = new CompetencyCatalogVersion
{
SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true,
};
db.CompetencyCatalogVersions.Insert(emptyVersion);
db.CompetencyCatalogVersions.Insert(catalogVersion);
var domain = new CompetencyDomain
{
SubjectId = subjectId, GradeLevel = 10, VersionId = catalogVersion.Id,
Name = "Erkenntnisgewinnung",
};
await applier.ApplyAsync(MakeEvent(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain));
var versions = db.CompetencyCatalogVersions.Find(v => v.SubjectId == subjectId).ToList();
Assert.True(versions.Single(v => v.Id == catalogVersion.Id).IsDefault);
Assert.False(versions.Single(v => v.Id == emptyVersion.Id).IsDefault);
}
[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,
};
}