Files
LehrerApp/LehrerApp.Data.Tests/LiteDbContextTests.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

454 lines
19 KiB
C#

using LehrerApp.Core.Models;
using LiteDB;
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class LiteDbContextTests
{
[Fact]
public void MigratesLegacyEnrollmentAndSubjectTextWithoutLosingData()
{
using var temp = new TempDatabase();
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var membershipId = Guid.NewGuid();
var examId = Guid.NewGuid();
var gradeId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var addedOn = new DateOnly(2026, 6, 23);
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<Subject>("subjects").Insert(new Subject { Name = "mathematik" });
legacy.GetCollection<BsonDocument>("groups").Insert(new BsonDocument
{
["_id"] = groupId,
[nameof(LearningGroup.Name)] = "Mathe G",
["Subject"] = "Mathematik",
[nameof(LearningGroup.SchoolYear)] = "2025/26",
});
legacy.GetCollection<BsonDocument>("enrollments").Insert(new BsonDocument
{
["_id"] = membershipId,
[nameof(GroupMembership.StudentId)] = studentId,
[nameof(GroupMembership.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
["EnrolledAt"] = BsonMapper.Global.Serialize(addedOn),
});
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
{
["_id"] = examId,
[nameof(Exam.GroupId)] = groupId,
["Subject"] = "Mathematik",
});
legacy.GetCollection<BsonDocument>("grades").Insert(new BsonDocument
{
["_id"] = gradeId,
[nameof(Grade.StudentId)] = studentId,
[nameof(Grade.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
});
legacy.GetCollection<BsonDocument>("participation").Insert(new BsonDocument
{
["_id"] = entryId,
[nameof(ParticipationEntry.SessionId)] = Guid.NewGuid(),
[nameof(ParticipationEntry.StudentId)] = studentId,
["GroupId"] = groupId,
["Date"] = BsonMapper.Global.Serialize(addedOn),
});
}
using (var context = new LiteDbContext(temp.Path))
{
var group = context.Groups.FindById(groupId);
var membership = context.Memberships.FindById(membershipId);
Assert.NotNull(group);
Assert.NotNull(group.SubjectId);
Assert.Equal("mathematik", context.Subjects.FindById(group.SubjectId.Value)?.Name);
Assert.Equal(1, context.Subjects.Count());
Assert.NotNull(membership);
Assert.Equal(addedOn, membership.AddedOn);
}
using var migrated = new LiteDatabase(temp.Path);
Assert.DoesNotContain("enrollments", migrated.GetCollectionNames());
Assert.Contains("group_memberships", migrated.GetCollectionNames());
var rawGroup = migrated.GetCollection<BsonDocument>("groups").FindById(groupId);
var rawMembership = migrated.GetCollection<BsonDocument>("group_memberships").FindById(membershipId);
var rawExam = migrated.GetCollection<BsonDocument>("exams").FindById(examId);
var rawGrade = migrated.GetCollection<BsonDocument>("grades").FindById(gradeId);
var rawEntry = migrated.GetCollection<BsonDocument>("participation").FindById(entryId);
Assert.False(rawGroup.ContainsKey("Subject"));
Assert.False(rawMembership.ContainsKey("SchoolYear"));
Assert.False(rawMembership.ContainsKey("EnrolledAt"));
Assert.True(rawMembership.ContainsKey(nameof(GroupMembership.AddedOn)));
Assert.False(rawExam.ContainsKey("Subject"));
Assert.False(rawGrade.ContainsKey("SchoolYear"));
Assert.False(rawEntry.ContainsKey("GroupId"));
Assert.False(rawEntry.ContainsKey("Date"));
}
[Fact]
public void RejectsASecondMembershipForTheSameStudentAndGroup()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
var studentId = Guid.NewGuid();
var groupId = Guid.NewGuid();
context.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId });
Assert.Throws<LiteException>(() => context.Memberships.Insert(
new GroupMembership { StudentId = studentId, GroupId = groupId }));
}
[Fact]
public void RejectsASecondExamResultForTheSameStudentAndExam()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
var studentId = Guid.NewGuid();
var examId = Guid.NewGuid();
context.ExamResults.Insert(new ExamResult { StudentId = studentId, ExamId = examId });
Assert.Throws<LiteException>(() => context.ExamResults.Insert(
new ExamResult { StudentId = studentId, ExamId = examId }));
}
[Fact]
public void NeueDatenbank_HatDieAktuelleSchemaVersionNachDemErstenOeffnen()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
Assert.Equal(7, context.SchemaVersion);
}
[Fact]
public void SchemaVersion_WirdUeberEinenNeustartHinwegBeibehalten()
{
using var temp = new TempDatabase();
using (var first = new LiteDbContext(temp.Path)) { }
using var second = new LiteDbContext(temp.Path);
Assert.Equal(7, second.SchemaVersion);
}
[Fact]
public void MigrateDashboardWarningSnoozeIds_SchluesseltLokaleEintraegeAufAbgeleiteteIdUm()
{
using var temp = new TempDatabase();
var studentId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = 6 });
legacy.GetCollection<DashboardWarningSnooze>("dashboard_warning_snoozes").Insert(new DashboardWarningSnooze
{
Id = Guid.NewGuid(), StudentId = studentId, WarningTag = "Verspätungen",
SnoozedUntil = new DateOnly(2026, 10, 12),
});
}
using var context = new LiteDbContext(temp.Path);
var snooze = Assert.Single(context.DashboardWarningSnoozes.FindAll());
Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Verspätungen"), snooze.Id);
Assert.Equal(new DateOnly(2026, 10, 12), snooze.SnoozedUntil);
}
[Fact]
public void MigrateCompetencyCatalogVersions_ErzeugtStandardversionJeFachUndVerknuepftBestehendeBereiche()
{
using var temp = new TempDatabase();
var subjectAId = Guid.NewGuid();
var subjectBId = Guid.NewGuid();
var domainA1Id = Guid.NewGuid();
var domainA2Id = Guid.NewGuid();
var domainBId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
var domains = legacy.GetCollection<BsonDocument>("competency_domains");
domains.Insert(new BsonDocument
{
["_id"] = domainA1Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich A1",
});
domains.Insert(new BsonDocument
{
["_id"] = domainA2Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 9, [nameof(CompetencyDomain.Name)] = "Bereich A2",
});
domains.Insert(new BsonDocument
{
["_id"] = domainBId, [nameof(CompetencyDomain.SubjectId)] = subjectBId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich B",
});
}
using var context = new LiteDbContext(temp.Path);
var versions = context.CompetencyCatalogVersions.FindAll().ToList();
var versionA = Assert.Single(versions, v => v.SubjectId == subjectAId);
var versionB = Assert.Single(versions, v => v.SubjectId == subjectBId);
Assert.True(versionA.IsDefault);
Assert.True(versionB.IsDefault);
Assert.Equal("Bestehender Kompetenzkatalog", versionA.Name);
var domainsAfter = context.CompetencyDomains.FindAll().ToDictionary(d => d.Id);
Assert.Equal(versionA.Id, domainsAfter[domainA1Id].VersionId);
Assert.Equal(versionA.Id, domainsAfter[domainA2Id].VersionId);
Assert.Equal(versionB.Id, domainsAfter[domainBId].VersionId);
}
[Fact]
public void MigrateCompetencyReferences_LoestCodesGegenKatalogAufUndBehaeltUnbekannteAlsText()
{
using var temp = new TempDatabase();
var unitId = Guid.NewGuid();
var examId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var itemId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("competency_domains").Insert(new BsonDocument
{
["_id"] = Guid.NewGuid(),
[nameof(CompetencyDomain.Name)] = "Bereich",
[nameof(CompetencyDomain.Items)] = new BsonArray
{
new BsonDocument
{
["_id"] = itemId,
[nameof(CompetencyItem.Code)] = "K1",
[nameof(CompetencyItem.Description)] = "Erste Kompetenz",
},
},
});
legacy.GetCollection<BsonDocument>("units").Insert(new BsonDocument
{
["_id"] = unitId,
[nameof(Unit.GroupId)] = Guid.NewGuid(),
[nameof(Unit.Competencies)] = new BsonArray { "K1", "ALT" },
});
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
{
["_id"] = examId,
[nameof(Exam.GroupId)] = Guid.NewGuid(),
[nameof(Exam.Tasks)] = new BsonArray
{
new BsonDocument { ["Nr"] = 1, ["MaxPoints"] = 10.0, ["CompetencyCodes"] = new BsonArray { "K1" } },
},
});
legacy.GetCollection<BsonDocument>("participation_sessions").Insert(new BsonDocument
{
["_id"] = sessionId,
[nameof(ParticipationSession.GroupId)] = Guid.NewGuid(),
["CompetencyCodes"] = new BsonArray { "K1" },
});
legacy.GetCollection<BsonDocument>("participation").Insert(new BsonDocument
{
["_id"] = entryId,
[nameof(ParticipationEntry.SessionId)] = sessionId,
[nameof(ParticipationEntry.StudentId)] = Guid.NewGuid(),
[nameof(ParticipationEntry.CompetencyRatings)] = new BsonArray
{
new BsonDocument { ["Code"] = "K1", ["Value"] = 2 },
new BsonDocument { ["Code"] = "UNBEKANNT", ["Value"] = 1 },
},
});
}
using var context = new LiteDbContext(temp.Path);
var unit = context.Units.FindById(unitId);
Assert.Equal(2, unit!.Competencies.Count);
var known = unit.Competencies.Single(c => c.Code == "K1");
Assert.Equal(itemId, known.ItemId);
Assert.Equal("Erste Kompetenz", known.Description);
var unknown = unit.Competencies.Single(c => c.Code == "ALT");
Assert.Equal(Guid.Empty, unknown.ItemId);
var exam = context.Exams.FindById(examId);
var task = Assert.Single(exam!.Tasks);
var examRef = Assert.Single(task.Competencies);
Assert.Equal(itemId, examRef.ItemId);
var session = context.ParticipationSessions.FindById(sessionId);
var sessionRef = Assert.Single(session!.Competencies);
Assert.Equal(itemId, sessionRef.ItemId);
// Rating mit unbekanntem Code wird verworfen (kein sinnvoll erhaltbarer Zahlenwert ohne
// erkennbare Identität) - nur das auflösbare Rating bleibt übrig.
var entry = context.ParticipationEntries.FindById(entryId);
var rating = Assert.Single(entry!.CompetencyRatings);
Assert.Equal(itemId, rating.ItemId);
Assert.Equal(2, rating.Value);
}
[Fact]
public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen()
{
using var temp = new TempDatabase();
var lessonWithDataId = Guid.NewGuid();
var lessonEmptyId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonWithDataId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
["Phase"] = "Einstieg",
["Methods"] = new BsonArray { "Gespräch", "Tafelbild" },
["Materials"] = new BsonArray { "Arbeitsblatt" },
});
// Alte Stunde ganz ohne Phase/Methoden/Materialien — soll leere Phases-Liste bekommen,
// nicht eine leere synthetisierte Zeile.
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonEmptyId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
});
}
using (var context = new LiteDbContext(temp.Path))
{
var withData = context.Lessons.FindById(lessonWithDataId);
Assert.NotNull(withData);
Assert.Single(withData.Phases);
Assert.Equal("Einstieg", withData.Phases[0].Name);
Assert.Equal("Gespräch; Tafelbild", withData.Phases[0].Activity);
Assert.Equal("Arbeitsblatt", withData.Phases[0].Material);
Assert.Equal(0, withData.Phases[0].DurationMinutes);
var empty = context.Lessons.FindById(lessonEmptyId);
Assert.NotNull(empty);
Assert.Empty(empty.Phases);
}
using var migrated = new LiteDatabase(temp.Path);
var raw = migrated.GetCollection<BsonDocument>("lessons").FindById(lessonWithDataId);
Assert.False(raw.ContainsKey("Phase"));
Assert.False(raw.ContainsKey("Methods"));
Assert.False(raw.ContainsKey("Materials"));
}
[Fact]
public void MigrateLessonShorthand_FasstVonUndNachInEinFreitextfeldZusammen()
{
using var temp = new TempDatabase();
var lessonId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["ShorthandFrom"] = "Tb", ["ShorthandTo"] = "SH" },
new BsonDocument { ["ShorthandFrom"] = "Plenum" }, // nur "Von" gesetzt, kein Pfeil
new BsonDocument { }, // weder Von noch Nach
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var lesson = context.Lessons.FindById(lessonId);
Assert.NotNull(lesson);
Assert.Equal(3, lesson.Phases.Count);
Assert.Equal("Tb->SH", lesson.Phases[0].Shorthand);
Assert.Equal("Plenum", lesson.Phases[1].Shorthand);
Assert.Equal("", lesson.Phases[2].Shorthand);
}
using var migratedShorthand = new LiteDatabase(temp.Path);
var rawLesson = migratedShorthand.GetCollection<BsonDocument>("lessons").FindById(lessonId);
var rawPhases = rawLesson["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandFrom"));
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo"));
}
[Fact]
public void MigrateLessonAlternativePaths_ErzeugtKatalogEintraegeUndVerweistDarauf()
{
using var temp = new TempDatabase();
var lesson1Id = Guid.NewGuid();
var lesson2Id = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson1Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" },
new BsonDocument { ["AlternativePath"] = "" }, // Hauptweg, kein Katalogeintrag
new BsonDocument { }, // gar kein Feld, unangetastet
},
});
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson2Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" }, // gleicher Name -> gleicher Katalogeintrag
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var catalog = context.AlternativeLessonPaths.FindAll().ToList();
var entry = Assert.Single(catalog);
Assert.Equal("Kurzversion", entry.Name);
var lesson1 = context.Lessons.FindById(lesson1Id)!;
Assert.Equal(entry.Id, lesson1.Phases[0].AlternativePathId);
Assert.Null(lesson1.Phases[1].AlternativePathId);
Assert.Null(lesson1.Phases[2].AlternativePathId);
var lesson2 = context.Lessons.FindById(lesson2Id)!;
Assert.Equal(entry.Id, lesson2.Phases[0].AlternativePathId); // wiederverwendeter Katalogeintrag
}
using var migrated = new LiteDatabase(temp.Path);
var rawLesson1 = migrated.GetCollection<BsonDocument>("lessons").FindById(lesson1Id);
var rawPhases = rawLesson1["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("AlternativePath"));
}
private sealed class TempDatabase : IDisposable
{
private readonly string _directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-tests-{Guid.NewGuid():N}");
public string Path { get; }
public TempDatabase()
{
Directory.CreateDirectory(_directory);
Path = System.IO.Path.Combine(_directory, "test.db");
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}