Baustein 4: Repositories verdrahten Teil B (Kaskaden & Batch, Kapitel 10)

Die heiklen Faelle, die nicht ueber die oeffentliche Save/Delete-Methode
laufen, sondern mehrere Collections direkt anfassen: GroupRepository
(Save + Delete-Kaskade), ExamRepository.Delete, ExamResultRepository
(Save/SaveMany), DocumentationRepository (Save/Delete/HardDelete),
ParticipationSessionRepository.Delete, ParticipationRepository
(SaveMany/DeleteBySession), CompetencyDomainRepository (inkl. Replace-
/DeleteBySubjectAndGrade).

Regel: pro oeffentlichem Repository-Aufruf genau EIN Sync-Ereignis
(z.B. GroupRepository.Delete -> ein Group/Delete-Ereignis, nicht 14),
Batch-Methoden feuern ein Ereignis pro betroffener Entitaet. Dafuer
ExamRepository.Delete/ParticipationSessionRepository.Delete/
DocumentationRepository.HardDelete auf die in Baustein 2 vorbereiteten
LiteDbContext-Kaskadenhelfer umgestellt (CascadeDeleteExam,
CascadeDeleteParticipationSession, CascadeHardDeleteDocumentation) -
dieselbe Kaskade existiert dadurch nur an einer Stelle im Code, nicht
doppelt (wichtig fuer Baustein 5, wo ein eingehendes Sync-Ereignis sie
erneut braucht).

Tests pruefen explizit die Ereignis-Anzahl bei Kaskaden/Batches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:24:22 +02:00
co-authored by Claude Sonnet 5
parent ed34e5d036
commit 1240a2cd8a
3 changed files with 268 additions and 13 deletions
@@ -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);
}
}
+26
View File
@@ -156,6 +156,32 @@ public class LiteDbContext : IDisposable
});
}
/// Führt dieselbe Kaskade wie <c>ExamRepository.Delete</c> aus (siehe <see cref="CascadeDeleteGroup"/>).
internal void CascadeDeleteExam(Guid id)
{
foreach (var result in ExamResults.Find(r => r.ExamId == id).ToList())
ExamResults.Delete(result.Id);
Exams.Delete(id);
}
/// Führt dieselbe Kaskade wie <c>ParticipationSessionRepository.Delete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
internal void CascadeDeleteParticipationSession(Guid id)
{
ParticipationSessions.Delete(id);
foreach (var e in ParticipationEntries.Find(e => e.SessionId == id).ToList())
ParticipationEntries.Delete(e.Id);
}
/// Führt dieselbe Kaskade wie <c>DocumentationRepository.HardDelete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
internal void CascadeHardDeleteDocumentation(Guid id)
{
if (Documentation.FindById(id) is { } doc)
foreach (var attachment in doc.Attachments) Attachments.Delete(attachment.StorageId);
Documentation.Delete(id);
}
internal void ExecuteInTransaction(Action action)
{
_db.BeginTrans();
+46 -13
View File
@@ -79,11 +79,13 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
g.UpdatedAt = DateTime.UtcNow;
db.Groups.Upsert(g);
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
}
public void Delete(Guid id)
{
ArchivedGroupWriteGuard.EnsureActive(db, id);
db.CascadeDeleteGroup(id);
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
}
}
@@ -181,14 +183,14 @@ public class ExamRepository(LiteDbContext db) : IExamRepository
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
e.UpdatedAt = DateTime.UtcNow;
db.Exams.Upsert(e);
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
}
public void Delete(Guid id)
{
if (db.Exams.FindById(id) is { } exam)
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
db.ExamResults.Delete(result.Id);
db.Exams.Delete(id);
db.CascadeDeleteExam(id);
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
}
}
@@ -206,6 +208,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
r.UpdatedAt = DateTime.UtcNow;
db.ExamResults.Upsert(r);
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
}
public void SaveMany(List<ExamResult> results)
{
@@ -215,6 +218,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
var now = DateTime.UtcNow;
foreach (var r in results) r.UpdatedAt = now;
db.ExamResults.Upsert(results);
foreach (var r in results)
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
}
}
@@ -356,7 +361,12 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
.OrderByDescending(d => d.Date).ToList();
public List<Documentation> GetAll() =>
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); }
public void Save(Documentation d)
{
d.UpdatedAt = DateTime.UtcNow;
db.Documentation.Upsert(d);
db.OnChange?.Invoke(nameof(Documentation), d.Id.ToString(), "Save", d);
}
public void Delete(Guid id)
{
var doc = db.Documentation.FindById(id);
@@ -364,13 +374,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
doc.IsDeleted = true;
doc.DeletedAt = DateTime.UtcNow;
db.Documentation.Update(doc);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen.
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Save", doc);
}
public void HardDelete(Guid id)
{
var doc = db.Documentation.FindById(id);
if (doc is not null)
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
db.Documentation.Delete(id);
db.CascadeHardDeleteDocumentation(id);
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
}
}
@@ -423,14 +434,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
s.UpdatedAt = DateTime.UtcNow;
db.ParticipationSessions.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
}
public void Delete(Guid id)
{
if (db.ParticipationSessions.FindById(id) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
db.ParticipationSessions.Delete(id);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
db.ParticipationEntries.Delete(e.Id);
db.CascadeDeleteParticipationSession(id);
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
}
}
@@ -448,6 +459,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
e.UpdatedAt = DateTime.UtcNow;
db.ParticipationEntries.Upsert(e);
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
}
public void SaveMany(List<ParticipationEntry> entries)
{
@@ -457,13 +469,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
var now = DateTime.UtcNow;
foreach (var e in entries) e.UpdatedAt = now;
db.ParticipationEntries.Upsert(entries);
foreach (var e in entries)
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
}
public void DeleteBySession(Guid sessionId)
{
if (db.ParticipationSessions.FindById(sessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
{
db.ParticipationEntries.Delete(e.Id);
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Delete", null);
}
}
}
@@ -682,14 +699,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.OrderBy(d => d.SortOrder)
.ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); }
public void Delete(Guid id) => db.CompetencyDomains.Delete(id);
public void Save(CompetencyDomain d)
{
d.UpdatedAt = DateTime.UtcNow;
db.CompetencyDomains.Upsert(d);
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Save", d);
}
public void Delete(Guid id)
{
db.CompetencyDomains.Delete(id);
db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null);
}
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
{
foreach (var d in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.ToList())
{
db.CompetencyDomains.Delete(d.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null);
}
}
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
{
@@ -697,7 +726,10 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
{
foreach (var domain in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
{
db.CompetencyDomains.Delete(domain.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
}
var now = DateTime.UtcNow;
foreach (var domain in domains)
{
@@ -705,6 +737,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
domain.GradeLevel = gradeLevel;
domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
}
});
}