using System.Net; using System.Text; using Microsoft.Extensions.Options; using Xunit; namespace LehrerApp.Api.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,\"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), Options.Create(new WebUntisOptions())); await Assert.ThrowsAsync( () => client.GetSchoolYearsAsync(CancellationToken.None)); Assert.Empty(handler.Requests); await client.DisposeAsync(); } [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), Options.Create(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), Options.Create(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 FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() { var handler = new QueueHandler( Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":{\"periodsWithAbsences\":[" + "{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," + "\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," + "\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," + "{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," + "\"absentTime\":45,\"checked\":false}]}}"), 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 report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731, CancellationToken.None); 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 absence = Assert.Single(report.Absences); Assert.Equal(45, report.AbsentMinutes); Assert.Equal("entschuldigt", absence.ExcuseStatus); Assert.Equal(7, Assert.Single(absence.TeacherIds)); 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\":\"getTimetableWithAbsences\"", handler.Requests[1].Body); Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body); Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body); Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\"")); await client.DisposeAsync(); Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body); } private static WebUntisClient CreateClient(HttpMessageHandler handler) => new( new HttpClient(handler), Options.Create(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 record CapturedRequest(string Uri, string Body, string Cookie); }