feat: add student master data import
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public sealed class StudentMasterDataCsvImportHandlerTests
|
||||
{
|
||||
private readonly StudentMasterDataCsvImportHandler _handler = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_ErkenntDieCharakteristischenSpaltenUnabhaengigVomDateinamen()
|
||||
{
|
||||
var detection = await _handler.DetectAsync(File(ValidFile(), "export.data"));
|
||||
|
||||
Assert.True(detection.IsMatch);
|
||||
Assert.Equal(95, detection.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_LehntEineBeliebigeCsvDateiAb()
|
||||
{
|
||||
var detection = await _handler.DetectAsync(File("Vorname;Nachname\nMax;Beispiel"));
|
||||
|
||||
Assert.False(detection.IsMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UebernimmtDieUnterstuetztenStammdaten()
|
||||
{
|
||||
var result = await _handler.ParseAsync(File(ValidFile()));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
|
||||
var first = result.Items[0];
|
||||
Assert.Equal("Max", first.FirstName);
|
||||
Assert.Equal("Beispiel", first.LastName);
|
||||
Assert.Equal(Gender.M, first.Gender);
|
||||
Assert.Equal(new DateOnly(2010, 1, 2), first.DateOfBirth);
|
||||
Assert.Equal("4711", first.ExternalId);
|
||||
Assert.Equal("10c", first.GroupName);
|
||||
Assert.Equal("Zeile 2", first.SourceReference);
|
||||
Assert.Equal("max@example.invalid", first.Contact?.Email);
|
||||
Assert.Equal("01234", first.Contact?.Phone);
|
||||
Assert.Equal("Musterweg 1", first.Contact?.Street);
|
||||
|
||||
Assert.Equal(Gender.W, result.Items[1].Gender);
|
||||
Assert.Empty(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UngueltigesGeburtsdatumErzeugtEinenFehlerMitZeilenangabe()
|
||||
{
|
||||
var text = ValidFile().Replace("02.01.2010", "2010-01-02");
|
||||
|
||||
var result = await _handler.ParseAsync(File(text));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var error = Assert.Single(result.Messages,
|
||||
message => message.Code == "master-data.birth-date.invalid");
|
||||
Assert.Equal("Zeile 2", error.SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_BehandeltTabulatorenUndAnfuehrungszeichenInTextfeldern()
|
||||
{
|
||||
var text = ValidFile().Replace("Musterweg 1", "\"Musterweg\t\"\"Nord\"\" 1\"");
|
||||
|
||||
var result = await _handler.ParseAsync(File(text));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
}
|
||||
|
||||
private static ImportFile File(string text, string name = "students.csv") =>
|
||||
new(name, Encoding.UTF8.GetBytes(text));
|
||||
|
||||
private static string ValidFile()
|
||||
{
|
||||
var header = string.Join('\t',
|
||||
[
|
||||
"name", "longName", "foreName", "gender", "birthDate", "klasse.name",
|
||||
"entryDate", "exitDate", "text", "id", "externKey", "medicalReportDuty",
|
||||
"schulpflicht", "majority", "address.email", "address.mobile", "address.phone",
|
||||
"address.city", "address.postCode", "address.street", "attribute.iL",
|
||||
]);
|
||||
var first = string.Join('\t',
|
||||
[
|
||||
"max.b", "Beispiel", "Max", "m", "02.01.2010", "10c",
|
||||
"01.08.2021", "", "", "100", "4711", "false", "false", "false",
|
||||
"max@example.invalid", "", "01234", "Beispielstadt", "12345", "Musterweg 1", "max.b",
|
||||
]);
|
||||
var second = string.Join('\t',
|
||||
[
|
||||
"erika.m", "Mustermann", "Erika", "w", "03.04.2010", "10c",
|
||||
"01.08.2021", "", "", "101", "4712", "false", "false", "false",
|
||||
"", "", "", "", "", "", "erika.m",
|
||||
]);
|
||||
return string.Join('\n', header, first, second) + "\n";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user