diff --git a/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs b/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs new file mode 100644 index 0000000..47ed3a4 --- /dev/null +++ b/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs @@ -0,0 +1,204 @@ +using LehrerApp.Core.Models; +using LehrerApp.Data.Repositories; +using Xunit; + +namespace LehrerApp.Data.Tests; + +// Baustein 3: prüft für jedes der ~19 "einfachen" Repositories (keine Kaskaden/Batches), dass +// Save/Delete den OnChange-Hook mit dem korrekten EntityType auslöst. Tabellengetrieben statt +// eine Datei pro Repository, um die immer gleiche Prüfung nicht 19x zu wiederholen. +public sealed class ChangeHookMatrixTests +{ + public static IEnumerable Cases() + { + yield return Case("SeatingPlan", nameof(SeatingPlan), db => + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var repo = new SeatingPlanRepository(db); + var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 }; + repo.Save(plan); + return (plan.Id, () => repo.Delete(plan.Id)); + }); + + yield return Case("GroupMembership", nameof(GroupMembership), db => + { + var repo = new GroupMembershipRepository(db); + var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid() }; + repo.Save(membership); + return (membership.Id, () => repo.Delete(membership.Id)); + }); + + yield return Case("GradingKeyTemplate", nameof(GradingKeyTemplate), db => + { + var repo = new GradingKeyTemplateRepository(db); + var template = new GradingKeyTemplate { Name = "Standard", GradingSystem = GradingSystem.Points0To15 }; + repo.Save(template); + return (template.Id, () => repo.Delete(template.Id)); + }); + + yield return Case("Grade", nameof(Grade), db => + { + var repo = new GradeRepository(db); + var grade = new Grade { GroupId = Guid.NewGuid(), StudentId = Guid.NewGuid(), Value = "2" }; + repo.Save(grade); + return (grade.Id, () => repo.Delete(grade.Id)); + }); + + yield return Case("GradingScheme", nameof(GradingScheme), db => + { + var repo = new GradingSchemeRepository(db); + var scheme = new GradingScheme { GroupId = Guid.NewGuid(), ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 }; + repo.Save(scheme); + return (scheme.Id, () => repo.Delete(scheme.Id)); + }); + + yield return Case("ReportGrade", nameof(ReportGrade), db => + { + var repo = new ReportGradeRepository(db); + var grade = new ReportGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = "1. Halbjahr" }; + repo.Save(grade); + return (grade.Id, () => repo.Delete(grade.Id)); + }); + + yield return Case("Unit", nameof(Unit), db => + { + var repo = new UnitRepository(db); + var unit = new Unit { GroupId = Guid.NewGuid(), Title = "Einheit" }; + repo.Save(unit); + return (unit.Id, () => repo.Delete(unit.Id)); + }); + + yield return Case("Lesson", nameof(Lesson), db => + { + var repo = new LessonRepository(db); + var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 3, 12) }; + repo.Save(lesson); + return (lesson.Id, () => repo.Delete(lesson.Id)); + }); + + yield return Case("WorkTask", nameof(WorkTask), db => + { + var repo = new WorkTaskRepository(db); + var task = new WorkTask { Title = "Klausur korrigieren", Category = TaskCategory.Correction }; + repo.Save(task); + return (task.Id, () => repo.Delete(task.Id)); + }); + + yield return Case("TimeEntry", nameof(TimeEntry), db => + { + var repo = new TimeEntryRepository(db); + var entry = new TimeEntry { Date = new DateOnly(2026, 3, 12), DurationMinutes = 30, Category = "Vorbereitung" }; + repo.Save(entry); + return (entry.Id, () => repo.Delete(entry.Id)); + }); + + yield return Case("ParticipationAspect", nameof(ParticipationAspect), db => + { + var repo = new ParticipationAspectRepository(db); + var aspect = new ParticipationAspect { GroupId = Guid.NewGuid(), Key = "quality", Label = "Qualität" }; + repo.Save(aspect); + return (aspect.Id, () => repo.Delete(aspect.Id)); + }); + + yield return Case("ParticipationSection", nameof(ParticipationSection), db => + { + var repo = new ParticipationSectionRepository(db); + var section = new ParticipationSection + { + GroupId = Guid.NewGuid(), Label = "1. Abschnitt", + StartDate = new DateOnly(2026, 3, 1), EndDate = new DateOnly(2026, 4, 1), + }; + repo.Save(section); + return (section.Id, () => repo.Delete(section.Id)); + }); + + yield return Case("Subject", nameof(Subject), db => + { + var repo = new SubjectRepository(db); + var subject = new Subject { Name = "Chemie" }; + repo.Save(subject); + return (subject.Id, () => repo.Delete(subject.Id)); + }); + + yield return Case("ShorthandCode", nameof(ShorthandCode), db => + { + var repo = new ShorthandCodeRepository(db); + var code = new ShorthandCode { Code = "Tb", Label = "Tafelbild" }; + repo.Save(code); + return (code.Id, () => repo.Delete(code.Id)); + }); + + yield return Case("AlternativeLessonPath", nameof(AlternativeLessonPath), db => + { + var repo = new AlternativeLessonPathRepository(db); + var path = new AlternativeLessonPath { Name = "Kurzversion" }; + repo.Save(path); + return (path.Id, () => repo.Delete(path.Id)); + }); + + yield return Case("TimetableSlot", nameof(TimetableSlot), db => + { + var repo = new TimetableSlotRepository(db); + var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }; + repo.Save(slot); + return (slot.Id, () => repo.Delete(slot.Id)); + }); + + yield return Case("SchoolHoliday", nameof(SchoolHoliday), db => + { + var repo = new SchoolHolidayRepository(db); + var holiday = new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) }; + repo.Save(holiday); + return (holiday.Id, () => repo.Delete(holiday.Id)); + }); + + yield return Case("SupervisionDuty", nameof(SupervisionDuty), db => + { + var repo = new SupervisionDutyRepository(db); + var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }; + repo.Save(duty); + return (duty.Id, () => repo.Delete(duty.Id)); + }); + + yield return Case("SubstitutionEntry", nameof(SubstitutionEntry), db => + { + var repo = new SubstitutionEntryRepository(db); + var entry = new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" }; + repo.Save(entry); + return (entry.Id, () => repo.Delete(entry.Id)); + }); + } + + [Theory] + [MemberData(nameof(Cases))] + public void Save_LoestOnChangeMitKorrektemEntityTypeAus( + string _, string entityType, Func setup) + { + using var db = new LiteDbContext(new MemoryStream()); + var calls = new List<(string EntityType, string Operation)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, op)); + + setup(db); + + Assert.Contains((entityType, "Save"), calls); + } + + [Theory] + [MemberData(nameof(Cases))] + public void Delete_LoestOnChangeMitKorrektemEntityTypeAus( + string _, string entityType, Func setup) + { + using var db = new LiteDbContext(new MemoryStream()); + var (id, delete) = setup(db); + var calls = new List<(string EntityType, string EntityId, string Operation)>(); + db.OnChange = (type, entId, op, payload) => calls.Add((type, entId, op)); + + delete(); + + Assert.Contains((entityType, id.ToString(), "Delete"), calls); + } + + private static object[] Case(string name, string entityType, Func setup) => + [name, entityType, setup]; +} diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 7c9af89..f4adf46 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -132,6 +132,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository plan.UpdatedAt = DateTime.UtcNow; db.SeatingPlans.Upsert(plan); + db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan); } public void Delete(Guid id) @@ -139,6 +140,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository if (db.SeatingPlans.FindById(id) is { } plan) ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); db.SeatingPlans.Delete(id); + db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null); } } @@ -157,12 +159,14 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos if (existing is not null && existing.Id != membership.Id) throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); db.Memberships.Upsert(membership); + db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership); } public void Delete(Guid id) { if (db.Memberships.FindById(id) is { } membership) ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); db.Memberships.Delete(id); + db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null); } } @@ -221,8 +225,17 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat public List GetByGradingSystem(GradingSystem system) => db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList(); public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id); - public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); } - public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id); + public void Save(GradingKeyTemplate t) + { + t.UpdatedAt = DateTime.UtcNow; + db.GradingKeyTemplates.Upsert(t); + db.OnChange?.Invoke(nameof(GradingKeyTemplate), t.Id.ToString(), "Save", t); + } + public void Delete(Guid id) + { + db.GradingKeyTemplates.Delete(id); + db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null); + } } public class GradeRepository(LiteDbContext db) : IGradeRepository @@ -235,12 +248,14 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository { ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); db.Grades.Upsert(g); + db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g); } public void Delete(Guid id) { if (db.Grades.FindById(id) is { } grade) ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); db.Grades.Delete(id); + db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null); } } @@ -254,12 +269,14 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); + db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); db.GradingSchemes.Delete(id); + db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null); } } @@ -273,12 +290,14 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId); r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); + db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r); } public void Delete(Guid id) { if (db.ReportGrades.FindById(id) is { } grade) ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); db.ReportGrades.Delete(id); + db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null); } } @@ -292,12 +311,14 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); + db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u); } public void Delete(Guid id) { if (db.Units.FindById(id) is { } unit) ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId); db.Units.Delete(id); + db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null); } } @@ -315,12 +336,14 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId); l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); + db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l); } public void Delete(Guid id) { if (db.Lessons.FindById(id) is { } lesson) ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); db.Lessons.Delete(id); + db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null); } } @@ -357,8 +380,17 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); public List GetAll() => db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); - public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); } - public void Delete(Guid id) => db.Tasks.Delete(id); + public void Save(WorkTask t) + { + t.UpdatedAt = DateTime.UtcNow; + db.Tasks.Upsert(t); + db.OnChange?.Invoke(nameof(WorkTask), t.Id.ToString(), "Save", t); + } + public void Delete(Guid id) + { + db.Tasks.Delete(id); + db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null); + } } public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository @@ -369,8 +401,16 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList(); public List GetByTask(Guid id) => db.TimeEntries.Find(e => e.TaskId == id).ToList(); - public void Save(TimeEntry e) => db.TimeEntries.Upsert(e); - public void Delete(Guid id) => db.TimeEntries.Delete(id); + public void Save(TimeEntry e) + { + db.TimeEntries.Upsert(e); + db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e); + } + public void Delete(Guid id) + { + db.TimeEntries.Delete(id); + db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null); + } } public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository @@ -453,12 +493,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits."); a.UpdatedAt = DateTime.UtcNow; db.ParticipationAspects.Upsert(a); + db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a); } public void Delete(Guid id) { if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); db.ParticipationAspects.Delete(id); + db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null); } } @@ -470,12 +512,14 @@ public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSe { ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); db.ParticipationSections.Upsert(s); + db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.ParticipationSections.FindById(id) is { } section) ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId); db.ParticipationSections.Delete(id); + db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null); } } @@ -495,12 +539,14 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits."); s.UpdatedAt = DateTime.UtcNow; db.Subjects.Upsert(s); + db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id)) throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet."); db.Subjects.Delete(id); + db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null); } } @@ -518,8 +564,13 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits."); c.UpdatedAt = DateTime.UtcNow; db.ShorthandCodes.Upsert(c); + db.OnChange?.Invoke(nameof(ShorthandCode), c.Id.ToString(), "Save", c); + } + public void Delete(Guid id) + { + db.ShorthandCodes.Delete(id); + db.OnChange?.Invoke(nameof(ShorthandCode), id.ToString(), "Delete", null); } - public void Delete(Guid id) => db.ShorthandCodes.Delete(id); } public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository @@ -537,8 +588,13 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits."); p.UpdatedAt = DateTime.UtcNow; db.AlternativeLessonPaths.Upsert(p); + db.OnChange?.Invoke(nameof(AlternativeLessonPath), p.Id.ToString(), "Save", p); + } + public void Delete(Guid id) + { + db.AlternativeLessonPaths.Delete(id); + db.OnChange?.Invoke(nameof(AlternativeLessonPath), id.ToString(), "Delete", null); } - public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id); } public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository @@ -555,20 +611,30 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor if (occupied is not null && occupied.Id != slot.Id) throw new InvalidOperationException("Diese Stunde ist bereits belegt."); db.TimetableSlots.Upsert(slot); + db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot); } public void Delete(Guid id) { if (db.TimetableSlots.FindById(id) is { } slot) ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); db.TimetableSlots.Delete(id); + db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null); } } public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository { public List GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList(); - public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday); - public void Delete(Guid id) => db.SchoolHolidays.Delete(id); + public void Save(SchoolHoliday holiday) + { + db.SchoolHolidays.Upsert(holiday); + db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday); + } + public void Delete(Guid id) + { + db.SchoolHolidays.Delete(id); + db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null); + } } public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository @@ -582,8 +648,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos if (occupied is not null && occupied.Id != duty.Id) throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); db.SupervisionDuties.Upsert(duty); + db.OnChange?.Invoke(nameof(SupervisionDuty), duty.Id.ToString(), "Save", duty); + } + public void Delete(Guid id) + { + db.SupervisionDuties.Delete(id); + db.OnChange?.Invoke(nameof(SupervisionDuty), id.ToString(), "Delete", null); } - public void Delete(Guid id) => db.SupervisionDuties.Delete(id); } public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository @@ -591,8 +662,16 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR public List GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List GetByDate(DateOnly date) => db.SubstitutionEntries.Find(e => e.Date == date).ToList(); - public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry); - public void Delete(Guid id) => db.SubstitutionEntries.Delete(id); + public void Save(SubstitutionEntry entry) + { + db.SubstitutionEntries.Upsert(entry); + db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry); + } + public void Delete(Guid id) + { + db.SubstitutionEntries.Delete(id); + db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null); + } } public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository