diff --git a/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs b/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs new file mode 100644 index 0000000..61249d7 --- /dev/null +++ b/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs @@ -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 + { + 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 + { + 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); + } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index bd7768b..164c2bd 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -156,6 +156,32 @@ public class LiteDbContext : IDisposable }); } + /// Führt dieselbe Kaskade wie ExamRepository.Delete aus (siehe ). + 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 ParticipationSessionRepository.Delete aus (siehe + /// ). + 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 DocumentationRepository.HardDelete aus (siehe + /// ). + 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(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index f4adf46..d59e0ed 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -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 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 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 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 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); } }); }