feat: add student master data import

This commit is contained in:
2026-08-17 13:12:03 +02:00
parent 9286bfa3b5
commit 37f4fee574
18 changed files with 2359 additions and 13 deletions
@@ -0,0 +1,400 @@
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<ImportedStudent>([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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ImportedStudent> 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<string> extensions,
int confidence,
IReadOnlyList<ImportedStudent> imported) : IImportHandler<ImportedStudent>
{
public ImportFormatId FormatId => formatId;
public string DisplayName => displayName;
public IReadOnlyCollection<string> SupportedExtensions => extensions;
public ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default) =>
ValueTask.FromResult(ImportDetection.Match(confidence));
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(imported));
}
private sealed class InMemoryStudentRepository(IEnumerable<Student>? initial = null)
: IStudentRepository
{
public List<Student> Students { get; } = initial?.ToList() ?? [];
public Student? GetById(Guid id) => Students.FirstOrDefault(student => student.Id == id);
public List<Student> GetAll(bool includeInactive = false) => Students
.Where(student => includeInactive || student.IsActive)
.ToList();
public List<Student> 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<LearningGroup>? initial = null)
: IGroupRepository
{
public List<LearningGroup> Groups { get; } = initial?.ToList() ?? [];
public LearningGroup? GetById(Guid id) => Groups.FirstOrDefault(group => group.Id == id);
public List<LearningGroup> GetAll(bool includeInactive = false) => Groups
.Where(group => includeInactive || group.IsActive)
.ToList();
public List<LearningGroup> 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<GroupMembership> Memberships { get; } = [];
public List<GroupMembership> GetByStudent(Guid studentId) =>
Memberships.Where(membership => membership.StudentId == studentId).ToList();
public List<GroupMembership> 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);
}
}