using LehrerApp.Core.Importing; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using Xunit; namespace LehrerApp.Tests; public sealed class StudentImportServiceTests { private static readonly ImportFormatId TestFormat = new("test.students"); [Fact] public void ImportFormatId_NormalisiertDenWert() { var id = new ImportFormatId(" TEST.Students "); Assert.Equal(TestFormat, id); Assert.Equal("test.students", id.ToString()); } [Fact] public async Task Catalog_WaehltBeiGleicherKonfidenzDiePassendeDateiendung() { var csv = new FakeHandler(new ImportFormatId("test.csv"), "CSV", [".csv"], 80, []); var xml = new FakeHandler(new ImportFormatId("test.xml"), "XML", [".xml"], 80, []); var catalog = new ImportHandlerCatalog([xml, csv]); var detected = await catalog.DetectAsync(new ImportFile("klasse.csv", "data"u8.ToArray())); Assert.Same(csv, detected.Handler); } [Fact] public async Task Analyze_EindeutigerTrefferWirdAutomatischVorhandenemSchuelerZugeordnet() { var existing = ExistingStudent(); var repository = new InMemoryStudentRepository([existing]); var service = Service(repository, [ new ImportedStudent { FirstName = " Erika ", LastName = " Mustermann ", DateOfBirth = new DateOnly(2012, 4, 5), }, ]); var preview = await service.AnalyzeAsync(File()); Assert.True(preview.CanApply); Assert.Empty(preview.Conflicts); var resolution = Assert.Single(preview.Entries).AutomaticResolution; Assert.Equal(StudentImportResolutionKind.UseExisting, resolution?.Kind); Assert.Equal(existing.Id, resolution?.ExistingStudentId); } [Fact] public async Task Analyze_GleicherNameOhneEindeutigesGeburtsdatumErzeugtUINeutralenKonflikt() { var repository = new InMemoryStudentRepository([ExistingStudent()]); var service = Service(repository, [ new ImportedStudent { FirstName = "Erika", LastName = "Mustermann" }, ]); var preview = await service.AnalyzeAsync(File()); var conflict = Assert.Single(preview.Conflicts); Assert.Equal(conflict.Id, Assert.Single(preview.Entries).ConflictId); Assert.Contains(conflict.Options, option => option.Id == "create"); Assert.Contains(conflict.Options, option => option.Id == "skip"); Assert.Contains(conflict.Options, option => option.Id.StartsWith("existing:")); } [Fact] public async Task Apply_VerlangtFuerJedenKonfliktEineEntscheidung() { var repository = new InMemoryStudentRepository([ExistingStudent()]); var service = Service(repository, [ new ImportedStudent { FirstName = "Erika", LastName = "Mustermann" }, ]); var preview = await service.AnalyzeAsync(File()); var exception = await Assert.ThrowsAsync( () => service.ApplyAsync(preview)); Assert.Contains("alle Konflikte", exception.Message); Assert.Single(repository.Students); } [Fact] public async Task Apply_LegtNeueSchuelerErstNachDerVorschauAn() { var repository = new InMemoryStudentRepository(); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel", DateOfBirth = new DateOnly(2011, 9, 10), Gender = Gender.M, }, ]); var preview = await service.AnalyzeAsync(File()); Assert.Empty(repository.Students); var result = await service.ApplyAsync(preview); Assert.Equal(1, result.CreatedStudents); var created = Assert.Single(repository.Students); Assert.Equal("Max", created.FirstName); Assert.Equal(Gender.M, created.Gender); } [Fact] public async Task Apply_NachAenderungDerBestandsdatenVerlangtNeueVorschau() { var existing = ExistingStudent(); var repository = new InMemoryStudentRepository([existing]); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }, ]); var preview = await service.AnalyzeAsync(File()); existing.UpdatedAt = existing.UpdatedAt.AddSeconds(1); var exception = await Assert.ThrowsAsync( () => service.ApplyAsync(preview)); Assert.Contains("Vorschau", exception.Message); Assert.Single(repository.Students); } [Fact] public async Task Analyze_FehlenderNachnameVerhindertApply() { var repository = new InMemoryStudentRepository(); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = " " }, ]); var preview = await service.AnalyzeAsync(File()); Assert.False(preview.CanApply); Assert.Contains(preview.Messages, message => message.Code == "student.last-name.required"); await Assert.ThrowsAsync(() => service.ApplyAsync(preview)); Assert.Empty(repository.Students); } [Fact] public async Task Analyze_EindeutigDoppelteImportzeilenWerdenNichtGespeichert() { var repository = new InMemoryStudentRepository(); var student = new ImportedStudent { FirstName = "Max", LastName = "Beispiel", DateOfBirth = new DateOnly(2011, 9, 10), }; var service = Service(repository, [student, student]); var preview = await service.AnalyzeAsync(File()); Assert.False(preview.CanApply); Assert.Contains(preview.Messages, message => message.Code == "student.duplicate"); await Assert.ThrowsAsync(() => service.ApplyAsync(preview)); Assert.Empty(repository.Students); } [Fact] public async Task Apply_ErgaenztEinenAusgewaehltenVorhandenenSchuelerOhneBestehendeWerteZuUeberschreiben() { var existing = new Student { FirstName = "Max", LastName = "Beispiel", Gender = Gender.M, }; var repository = new InMemoryStudentRepository([existing]); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel", Gender = Gender.W, DateOfBirth = new DateOnly(2011, 9, 10), ExternalId = "4711", Contact = new ImportedStudentContact { Email = "max@example.invalid", MobilePhone = "01234", Street = "Musterweg 1", }, }, ]); var preview = await service.AnalyzeAsync(File()); var conflict = Assert.Single(preview.Conflicts); var useExisting = conflict.Options.Single(option => option.Id.StartsWith("existing:")); var result = await service.ApplyAsync(preview, [new ImportDecision(conflict.Id, useExisting.Id)]); Assert.Equal(1, result.UpdatedStudents); Assert.Equal(new DateOnly(2011, 9, 10), existing.DateOfBirth); Assert.Equal(Gender.M, existing.Gender); Assert.Equal("4711", existing.ExternalIds[TestFormat.Value]); var contact = Assert.Single(existing.Contacts); Assert.Equal("Stammdaten", contact.Relation); Assert.Equal("max@example.invalid", contact.Email); Assert.Equal("01234", contact.MobilePhone); } [Fact] public async Task Apply_OrdnetBeiEindeutigerAktiverGruppeImAktuellenSchuljahrAutomatischZu() { var repository = new InMemoryStudentRepository(); var schoolYears = new SchoolYearService(); var group = new LearningGroup { Name = "10c", SchoolYear = schoolYears.CurrentSchoolYear(), GradeLevel = 10, IsActive = true, }; var groups = new InMemoryGroupRepository([group]); var memberships = new InMemoryGroupMembershipRepository(); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel", GroupName = "10C", }, ], groups, memberships, schoolYears); var preview = await service.AnalyzeAsync(File()); Assert.Empty(preview.Conflicts); Assert.Equal(group.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId); var result = await service.ApplyAsync(preview); Assert.Equal(1, result.CreatedMemberships); var membership = Assert.Single(memberships.Memberships); Assert.Equal(group.Id, membership.GroupId); Assert.Equal(Assert.Single(repository.Students).Id, membership.StudentId); } [Fact] public async Task Analyze_GruppeAusAnderemSchuljahrWirdNichtAutomatischVerwendet() { var repository = new InMemoryStudentRepository(); var schoolYears = new SchoolYearService(); var oldGroup = new LearningGroup { Name = "10c", SchoolYear = schoolYears.RecentSchoolYears(2)[1], GradeLevel = 10, IsActive = true, }; var groups = new InMemoryGroupRepository([oldGroup]); var memberships = new InMemoryGroupMembershipRepository(); var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel", GroupName = "10c", }, ], groups, memberships, schoolYears); var preview = await service.AnalyzeAsync(File()); Assert.Null(Assert.Single(preview.GroupAssignments).AutomaticGroupId); Assert.Contains(preview.Messages, message => message.Code == "student.group.not-found"); } [Fact] public async Task Analyze_BevorzugtBeiMehrerenNamensgleichenGruppenDieEigeneKlasse() { var repository = new InMemoryStudentRepository(); var schoolYears = new SchoolYearService(); var schoolYear = schoolYears.CurrentSchoolYear(); var subjectGroup = new LearningGroup { Name = "10c", SchoolYear = schoolYear, GradeLevel = 10, Type = GroupType.Class, IsOwnClass = false, }; var ownClass = new LearningGroup { Name = "10c", SchoolYear = schoolYear, GradeLevel = 10, Type = GroupType.Class, IsOwnClass = true, }; var service = Service(repository, [ new ImportedStudent { FirstName = "Max", LastName = "Beispiel", GroupName = "10c", }, ], new InMemoryGroupRepository([subjectGroup, ownClass]), new InMemoryGroupMembershipRepository(), schoolYears); var preview = await service.AnalyzeAsync(File()); Assert.Empty(preview.Conflicts); Assert.Equal(ownClass.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId); } private static StudentImportService Service( InMemoryStudentRepository repository, IReadOnlyList imported, InMemoryGroupRepository? groups = null, InMemoryGroupMembershipRepository? memberships = null, SchoolYearService? schoolYears = null) => new( [new FakeHandler(TestFormat, "Testformat", [".test"], 100, imported)], repository, groups ?? new InMemoryGroupRepository(), memberships ?? new InMemoryGroupMembershipRepository(), schoolYears ?? new SchoolYearService()); private static ImportFile File() => new("schueler.test", "test"u8.ToArray()); private static Student ExistingStudent() => new() { FirstName = "Erika", LastName = "Mustermann", DateOfBirth = new DateOnly(2012, 4, 5), }; private sealed class FakeHandler( ImportFormatId formatId, string displayName, IReadOnlyCollection extensions, int confidence, IReadOnlyList imported) : IImportHandler { public ImportFormatId FormatId => formatId; public string DisplayName => displayName; public IReadOnlyCollection SupportedExtensions => extensions; public ValueTask DetectAsync( ImportFile file, CancellationToken cancellationToken = default) => ValueTask.FromResult(ImportDetection.Match(confidence)); public ValueTask> ParseAsync( ImportFile file, CancellationToken cancellationToken = default) => ValueTask.FromResult(new ImportParseResult(imported)); } private sealed class InMemoryStudentRepository(IEnumerable? initial = null) : IStudentRepository { public List Students { get; } = initial?.ToList() ?? []; public Student? GetById(Guid id) => Students.FirstOrDefault(student => student.Id == id); public List GetAll(bool includeInactive = false) => Students .Where(student => includeInactive || student.IsActive) .ToList(); public List GetByGroup(Guid groupId) => []; public StudentReferenceSummary GetReferenceSummary(Guid studentId) => new(0, 0, 0, 0, 0, 0); public void Save(Student student) { var index = Students.FindIndex(existing => existing.Id == student.Id); if (index >= 0) Students[index] = student; else Students.Add(student); } public void Delete(Guid id) => Students.RemoveAll(student => student.Id == id); } private sealed class InMemoryGroupRepository(IEnumerable? initial = null) : IGroupRepository { public List Groups { get; } = initial?.ToList() ?? []; public LearningGroup? GetById(Guid id) => Groups.FirstOrDefault(group => group.Id == id); public List GetAll(bool includeInactive = false) => Groups .Where(group => includeInactive || group.IsActive) .ToList(); public List GetBySchoolYear(string schoolYear, bool includeInactive = false) => Groups .Where(group => group.SchoolYear == schoolYear && (includeInactive || group.IsActive)) .ToList(); public void Save(LearningGroup group) => Groups.Add(group); public void Delete(Guid id) => Groups.RemoveAll(group => group.Id == id); } private sealed class InMemoryGroupMembershipRepository : IGroupMembershipRepository { public List Memberships { get; } = []; public List GetByStudent(Guid studentId) => Memberships.Where(membership => membership.StudentId == studentId).ToList(); public List GetByGroup(Guid groupId) => Memberships.Where(membership => membership.GroupId == groupId).ToList(); public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) => Memberships.FirstOrDefault(membership => membership.StudentId == studentId && membership.GroupId == groupId); public void Save(GroupMembership membership) => Memberships.Add(membership); public void Delete(Guid id) => Memberships.RemoveAll(membership => membership.Id == id); } }