Files
LehrerApp/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
T
admin a3cd57e5d8
CI / build-and-test (push) Canceled after 0s
Fix class teacher full-day absence classification
2026-10-08 11:20:17 +02:00

418 lines
20 KiB
C#

using System.Net;
using System.Diagnostics;
using System.Text;
using LehrerApp.WebUntis;
using Xunit;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClientTests
{
[Fact]
public async Task WeeklyLessonIds_RejectConflictingPeriodAssignments()
{
var handler = new QueueHandler(
Json("""{"result":{"sessionId":"s"}}"""),
Json("""{"data":{"result":{"data":{"elementPeriods":{"89":[{"id":10,"lessonId":100},{"id":10,"lessonId":200}]}}}}}"""),
Json("""{"result":{}}"""));
await using var client = CreateClient(handler);
await Assert.ThrowsAsync<WebUntisException>(() =>
client.GetWeeklyLessonIdsAsync(89, new DateOnly(2026, 10, 5), default));
Assert.Contains("elementType=2&elementId=89&date=2026-10-05&formatId=3", handler.Requests[1].Uri);
Assert.Contains("JSESSIONID=s", handler.Requests[1].Cookie);
}
[Fact]
public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose()
{
var latin1 = Encoding.Latin1.GetBytes(
"id\texternKey\tklasse.name\tlongName\tforeName\r\n" +
"1\t10\t7a\tMüller\tAda\r\n" +
"2\t20\t8b\tMeier\tBerta\r\n");
var handler = new QueueHandler(
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(latin1),
},
Json("{\"jsonrpc\":\"2.0\",\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"jsonrpc\":\"2.0\",\"result\":{}}"));
var client = CreateClient(handler);
var result = await client.GetStudentReportAsync("7a", CancellationToken.None);
var schoolYears = await client.GetSchoolYearsAsync(CancellationToken.None);
var student = Assert.Single(result.Students);
Assert.Equal("Ada Müller", student.DisplayName);
Assert.Equal("7a", result.ClassNameFilter);
Assert.Equal("2026/27", Assert.Single(schoolYears).Name);
Assert.Equal(4, handler.Requests.Count);
Assert.Contains("jsonrpc.do?school=meine-schule", handler.Requests[0].Uri);
Assert.Contains("\"method\":\"authenticate\"", handler.Requests[0].Body);
Assert.Contains("reports.do?name=Student", handler.Requests[1].Uri);
Assert.Contains("schoolname=\"_", handler.Requests[1].Cookie);
Assert.EndsWith("reports.do?foo=bar", handler.Requests[2].Uri);
Assert.Contains("\"method\":\"getSchoolyears\"", handler.Requests[3].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Equal(5, handler.Requests.Count);
Assert.Contains("\"method\":\"logout\"", handler.Requests[4].Body);
}
[Fact]
public async Task GetTimetableAsync_SendetElementUndZeitraumAlsSeparateAbfrage()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":99,\"date\":20260824,\"startTime\":800,\"endTime\":845," +
"\"kl\":[{\"id\":4,\"name\":\"7a\"}],\"te\":[],\"su\":[],\"ro\":[]}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var periods = await client.GetTimetableAsync(UntisTimetableElementType.Teacher, 42,
20260824, 20260828, CancellationToken.None);
var period = Assert.Single(periods);
Assert.Equal(99, period.Id);
Assert.Equal("7a", Assert.Single(period.Classes).Name);
Assert.Contains("\"method\":\"getTimetable\"", handler.Requests[1].Body);
Assert.Contains("\"element\":{\"id\":42,\"type\":2}", handler.Requests[1].Body);
Assert.Contains("\"startDate\":20260824", handler.Requests[1].Body);
await client.DisposeAsync();
}
[Fact]
public async Task GetTimetableGridAsync_ErzeugtBearerUndLiestExamMitDoppelblock()
{
var login = Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}");
login.Headers.TryAddWithoutValidation("Set-Cookie", "Tenant-Id=\"4026000\"; Path=/");
var jwtPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"exp\":4102444800}"))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var handler = new QueueHandler(
login,
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"x.{jwtPayload}.signature", Encoding.UTF8, "application/json"),
},
Json("{\"format\":1,\"errors\":[],\"days\":[{\"gridEntries\":[{" +
"\"ids\":[2002979,2002982],\"duration\":{\"start\":\"2026-09-29T09:40\"," +
"\"end\":\"2026-09-29T11:10\"},\"type\":\"EXAM\",\"status\":\"REGULAR\"," +
"\"lessonInfo\":\"Arbeit 1\",\"position1\":[{\"current\":{\"type\":\"CLASS\"," +
"\"shortName\":\"10c\"},\"removed\":null}],\"position2\":[{\"current\":{" +
"\"type\":\"SUBJECT\",\"shortName\":\"Mat_E\"},\"removed\":null}]}]}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetTimetableGridAsync(89, 22,
new DateOnly(2026, 9, 28), new DateOnly(2026, 10, 2), CancellationToken.None);
var exam = Assert.Single(entries);
Assert.Equal("EXAM", exam.Type);
Assert.Equal(new DateTime(2026, 9, 29, 9, 40, 0), exam.Start);
Assert.Equal(new DateTime(2026, 9, 29, 11, 10, 0), exam.End);
Assert.Equal([2002979, 2002982], exam.Ids);
Assert.Equal("10c", exam.CurrentResources.Single(resource => resource.Type == "CLASS").ShortName);
Assert.Equal("Mat_E", exam.CurrentResources.Single(resource => resource.Type == "SUBJECT").ShortName);
Assert.EndsWith("/WebUntis/api/token/new", handler.Requests[1].Uri);
Assert.Contains("/api/rest/view/v1/timetable/entries", handler.Requests[2].Uri);
Assert.Contains("start=2026-09-28&end=2026-10-02", handler.Requests[2].Uri);
Assert.Equal("Bearer x." + jwtPayload + ".signature", handler.Requests[2].Authorization);
Assert.Equal("22", handler.Requests[2].SchoolYearId);
Assert.Contains("Tenant-Id=\"4026000\"", handler.Requests[2].Cookie);
await client.DisposeAsync();
}
[Fact]
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
{
var handler = new QueueHandler();
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions());
await Assert.ThrowsAsync<WebUntisConfigurationException>(
() => client.GetSchoolYearsAsync(CancellationToken.None));
Assert.Empty(handler.Requests);
await client.DisposeAsync();
}
[Fact]
public async Task Dispose_BegrenztEinenNichtAntwortendenLogout()
{
var handler = new HangingLogoutHandler();
var client = CreateClient(handler);
await client.GetSchoolYearsAsync(CancellationToken.None);
var elapsed = Stopwatch.StartNew();
await client.DisposeAsync();
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5));
Assert.True(handler.LogoutWasCancelled);
}
[Fact]
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
{
School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
});
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task VollstaendigeServerUrl_WirdOhnePfadVerwendet()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
{
School = "bk-ostvest",
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
});
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task Klassenbuch_WirdTypisiertAbgerufen()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
"\"text\":\"Buch vergessen\"}]}"),
Json("{\"result\":[{\"id\":3,\"name\":\"Material\",\"longName\":\"Material vergessen\"," +
"\"groupId\":2}]}"),
Json("{\"result\":[{\"id\":2,\"name\":\"Organisation\"}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
var entry = Assert.Single(entries);
Assert.Equal("Ada Müller", entry.DisplayName);
Assert.Equal("Buch vergessen", entry.Text);
Assert.Equal(3, Assert.Single(categories).Id);
Assert.Equal("Organisation", Assert.Single(groups).Name);
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
}
[Fact]
public async Task GetLessonAbsencesAsync_LoestTeacherIdAusDerEigenenSessionAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\t" +
"Abwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt1.tmp&name=AbsencePerLesson&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var absences = await client.GetLessonAbsencesAsync(38262, 20250811, 20260710, CancellationToken.None);
var absence = Assert.Single(absences);
Assert.Equal("Erika Mustermann", absence.StudentName);
Assert.Equal(20251209, absence.Date);
Assert.Equal(185120, absence.ExternKey);
Assert.True(absence.ExternKeyInParentheses);
Assert.Equal(940, absence.StartTime);
Assert.True(absence.Counts);
Assert.Contains("reports.do?name=AbsencePerLesson", handler.Requests[1].Uri);
Assert.Contains("lsid=38262", handler.Requests[1].Uri);
Assert.Contains("teacherId=89", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20250811", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260710", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt1.tmp&name=AbsencePerLesson&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task GetClassRegisterEventsReportAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt2.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var report = await client.GetClassRegisterEventsReportAsync(20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(report);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal("NAT", entry.Subject);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("hedtrich", entry.TeacherUsername);
Assert.Equal("Mitarb. über Erwart.", entry.CategoryName);
Assert.Equal("Positiv", entry.CategoryGroup);
Assert.Equal("Arbeitet gut mit.", entry.Text);
Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task GetClassAbsencesAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
// Kopfzeile "Text" kommt zweimal vor (Spalte 3 leer, Spalte 12 die eigentliche Tagesnotiz) -
// deckt ab, dass die spätere Spalte beim Parsen die frühere überschreibt (siehe Parser-Kommentar).
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t6a\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt3.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetClassAbsencesAsync("KL874", 20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal(12345, entry.ExternKey);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal(1, entry.AbsentPeriods);
Assert.Equal(45, entry.AbsentMinutes);
Assert.Equal("HED", entry.TeacherUsernames);
Assert.Equal("Che", entry.Subject);
Assert.Equal("Absent", entry.AbsenceReason);
Assert.Equal("Krank gemeldet", entry.Note);
Assert.Equal(555, entry.EntryId);
Assert.Equal("24.08.26", entry.HandledOn);
Assert.True(entry.Counts);
Assert.Null(entry.ExcuseNote);
Assert.Equal(3, entry.PeriodNumber);
Assert.Equal("nicht entsch.", entry.Status);
Assert.True(entry.CountsAsFullDay);
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
{
School = "meine-schule",
Host = "meine-schule.webuntis.com",
Username = "api-user",
Password = "secret",
Client = "tests",
});
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new CapturedRequest(
request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken),
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : []),
request.Headers.Authorization?.ToString() ?? "",
string.Join("", request.Headers.TryGetValues("x-webuntis-api-school-year-id", out var years) ? years : [])));
return _responses.Count > 0
? _responses.Dequeue()
: throw new InvalidOperationException("Keine Testantwort mehr vorhanden.");
}
}
private sealed class HangingLogoutHandler : HttpMessageHandler
{
public bool LogoutWasCancelled { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var body = request.Content is null
? ""
: await request.Content.ReadAsStringAsync(cancellationToken);
if (body.Contains("\"method\":\"authenticate\""))
return Json("{\"result\":{\"sessionId\":\"s\"}}");
if (body.Contains("\"method\":\"getSchoolyears\""))
return Json("{\"result\":[]}");
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("Der simulierte Logout darf nicht regulär enden.");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
LogoutWasCancelled = true;
throw;
}
}
}
private sealed record CapturedRequest(string Uri, string Body, string Cookie, string Authorization,
string SchoolYearId);
}