Files
LehrerApp/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
T

311 lines
14 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 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 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("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();
}
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 : [])));
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);
}