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( () => 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(); } 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 _responses = new(responses); public List Requests { get; } = []; protected override async Task 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 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); }