Read the timetable from WebUntis
Schulcloud holds the material for a lesson but not the lesson: this school's course `times` are empty and it publishes the schedule in WebUntis. So "what do I have today, and has anything been cancelled" was unanswerable, and the timetable cannot be typed into a prompt either — it changes daily. core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes, the notes on each period, inline homework, the period id), untis_homework (the class register's list, which is not Schulcloud's tasks) and untis_lesson_topics (what earlier lessons of a series actually covered, which is what says where a subject got to). Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so every call is a POST, reads included. READ_METHODS is the guarantee instead, enforced at the single choke point and asserted by a test. It matters because the key can do what the app can — the live account holds W_OWN_ABSENCE, so the same key could report the user absent. What the live instance taught us, all recorded in docs/API.md: - `startDateTime` ends in Z and is local time. The 08:00 lesson reports 08:00Z, so new Date() would move every lesson by an hour or two. - A substitution is two periods, the original CANCELLED and the replacement IRREGULAR beside it, not one period with a changed teacher. - Announced tests live in the period's info text. The exam module is unused here, so getExams2017 is always empty and that field carries the tests. - A day with no lessons is not a holiday: the weeks this account spends in the company simply have no periods. - `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998. Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and -8524 a drifting clock; the tools name both, because no retry fixes either. Configuration is all four UNTIS_* values or none — three are identifiers and the fourth is a credential, so a half-filled block is a paste that went wrong. Without them the tools are not registered at all, since a tool that can only fail is worse than a missing one. whoami reports the WebUntis identity and survives a dead Schulcloud session, so "is the server reachable" no longer gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture run: that key belongs to the real school. 224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the Schulcloud checks in those runs answer 401 because this machine's session is logged out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
406
test/untis.test.ts
Normal file
406
test/untis.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import {
|
||||
assertReadMethod,
|
||||
splitLocal,
|
||||
UntisApiError,
|
||||
UntisClient,
|
||||
type UntisClient as UntisClientType,
|
||||
type UntisTimetable,
|
||||
} from '../src/core/untis.ts';
|
||||
import { readTimetable } from '../src/mcp/tools/untis.ts';
|
||||
|
||||
const CONFIG = { server: 'school.webuntis.com', school: 'demo', user: 'fia24b-test', secret: 'JBSWY3DPEHPK3PXP' };
|
||||
|
||||
/** Master data in the shape the live instance returns it. */
|
||||
const MASTER = {
|
||||
timeStamp: 1,
|
||||
subjects: [
|
||||
{ id: 35, name: 'IT-FIA', longName: 'Fachtheorie FIA' },
|
||||
{ id: 130, name: 'Eth', longName: 'Ethik' },
|
||||
],
|
||||
teachers: [
|
||||
{ id: 1, name: 'Ra', firstName: 'Kristin', lastName: 'Rammelt' },
|
||||
{ id: 2, name: 'Hy', firstName: 'Justus', lastName: 'Hoyme' },
|
||||
],
|
||||
rooms: [
|
||||
{ id: 325, name: '42', longName: 'Raum 42' },
|
||||
{ id: 1, name: '001', longName: 'GR - Praxis' },
|
||||
],
|
||||
klassen: [{ id: 2757, name: 'FIA24B', longName: 'Fachinformatiker' }],
|
||||
holidays: [
|
||||
{
|
||||
name: 'Weltkindertag',
|
||||
longName: 'Weltkindertag (20.09.2026)',
|
||||
startDate: '2026-09-20',
|
||||
endDate: '2026-09-20',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const USER_DATA = {
|
||||
userData: {
|
||||
displayName: 'Hamm Fabian',
|
||||
elemId: 19_031,
|
||||
elemType: 'STUDENT',
|
||||
schoolName: 'Andreas-Gordon-Schule',
|
||||
rights: ['CLASSREGISTER', 'W_OWN_ABSENCE'],
|
||||
},
|
||||
masterData: MASTER,
|
||||
};
|
||||
|
||||
/** A regular lesson, a cancelled one and its replacement in the same slot. */
|
||||
const PERIODS = [
|
||||
{
|
||||
id: 100,
|
||||
lessonId: 900,
|
||||
startDateTime: '2026-09-21T08:00Z',
|
||||
endDateTime: '2026-09-21T08:45Z',
|
||||
text: { lesson: '', substitution: '', info: 'Test Projektdoku', attachments: [] },
|
||||
elements: [
|
||||
{ type: 'CLASS', id: 2757, orgId: 2757 },
|
||||
{ type: 'TEACHER', id: 1, orgId: 1 },
|
||||
{ type: 'SUBJECT', id: 35, orgId: 35 },
|
||||
{ type: 'ROOM', id: 325, orgId: 325 },
|
||||
],
|
||||
is: ['REGULAR'],
|
||||
homeWorks: [
|
||||
{ id: 7, lessonId: 900, startDate: '2026-09-14', endDate: '2026-09-21', text: 'Plakat mitbringen', completed: false },
|
||||
],
|
||||
exam: null,
|
||||
isOnlinePeriod: false,
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
lessonId: 901,
|
||||
startDateTime: '2026-09-21T10:05Z',
|
||||
endDateTime: '2026-09-21T10:50Z',
|
||||
elements: [
|
||||
{ type: 'TEACHER', id: 1, orgId: 1 },
|
||||
{ type: 'SUBJECT', id: 35, orgId: 35 },
|
||||
],
|
||||
is: ['CANCELLED'],
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
lessonId: 902,
|
||||
startDateTime: '2026-09-21T10:05Z',
|
||||
endDateTime: '2026-09-21T10:50Z',
|
||||
text: { substitution: 'Unterricht vom 30.09.2026' },
|
||||
elements: [
|
||||
{ type: 'TEACHER', id: 2, orgId: 2 },
|
||||
{ type: 'SUBJECT', id: 130, orgId: 130 },
|
||||
{ type: 'ROOM', id: 325, orgId: 1 },
|
||||
],
|
||||
is: ['IRREGULAR'],
|
||||
},
|
||||
];
|
||||
|
||||
const savedFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
});
|
||||
|
||||
/** Captures every request and answers per method. */
|
||||
function stub(answers: Record<string, unknown>): { calls: { method: string; url: string; body: any }[] } {
|
||||
const calls: { method: string; url: string; body: any }[] = [];
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
calls.push({ method: body.method, url: String(url), body });
|
||||
const answer = answers[body.method];
|
||||
if (answer === undefined) return Response.json({ error: { code: -32_601, message: 'Method not found' } });
|
||||
if (answer instanceof Error) return Response.json({ error: { code: Number(answer.message), message: 'nope' } });
|
||||
return Response.json({ result: answer });
|
||||
}) as typeof fetch;
|
||||
return { calls };
|
||||
}
|
||||
|
||||
describe('UntisClient: request shape', () => {
|
||||
it('signs each request with a padded string code and the school in the query', async () => {
|
||||
const { calls } = stub({ getUserData2017: USER_DATA });
|
||||
await new UntisClient(CONFIG).identity();
|
||||
|
||||
const [call] = calls;
|
||||
assert.ok(call);
|
||||
assert.match(call.url, /^https:\/\/school\.webuntis\.com\/WebUntis\/jsonrpc_intern\.do\?/);
|
||||
assert.match(call.url, /m=getUserData2017/);
|
||||
assert.match(call.url, /school=demo/);
|
||||
// Without a version parameter the endpoint answers with a Java NPE.
|
||||
assert.match(call.url, /v=i3\.2/);
|
||||
const auth = call.body.params[0].auth;
|
||||
assert.equal(auth.user, 'fia24b-test');
|
||||
assert.equal(typeof auth.otp, 'string');
|
||||
assert.match(auth.otp, /^\d{6}$/);
|
||||
assert.ok(Math.abs(auth.clientTime - Date.now()) < 5_000);
|
||||
// The key itself never travels.
|
||||
assert.ok(!JSON.stringify(call.body).includes(CONFIG.secret));
|
||||
});
|
||||
|
||||
it('refuses any method outside the read-only allowlist', () => {
|
||||
assert.throws(() => assertReadMethod('submitAbsencesChecked2017'), /read-only allowlist/);
|
||||
assert.throws(() => assertReadMethod('createImmediateAbsence2017'), /read-only allowlist/);
|
||||
assert.doesNotThrow(() => assertReadMethod('getTimetable2017'));
|
||||
});
|
||||
|
||||
it('names the two failures a person has to act on', async () => {
|
||||
for (const [code, flag] of [
|
||||
[-8504, 'isAuthFailure'],
|
||||
[-8524, 'isClockSkew'],
|
||||
] as const) {
|
||||
stub({ getUserData2017: new Error(String(code)) });
|
||||
const error = await new UntisClient(CONFIG).identity().then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
assert.ok(error instanceof UntisApiError, `code ${code}`);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error[flag], true);
|
||||
}
|
||||
});
|
||||
|
||||
it('caches the identity but not a failure', async () => {
|
||||
const failing = stub({ getUserData2017: new Error('-8504') });
|
||||
const client = new UntisClient(CONFIG);
|
||||
await client.identity().catch(() => undefined);
|
||||
await client.identity().catch(() => undefined);
|
||||
assert.equal(failing.calls.length, 2, 'a rejected key must be retried after it is fixed');
|
||||
|
||||
const working = stub({ getUserData2017: USER_DATA });
|
||||
const cached = new UntisClient(CONFIG);
|
||||
const first = await cached.identity();
|
||||
const second = await cached.identity();
|
||||
assert.equal(working.calls.length, 1);
|
||||
assert.equal(first.displayName, 'Hamm Fabian');
|
||||
assert.deepEqual(second.rights, ['CLASSREGISTER', 'W_OWN_ABSENCE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UntisClient: timetable', () => {
|
||||
const load = async (): Promise<UntisTimetable> => {
|
||||
stub({ getUserData2017: USER_DATA, getTimetable2017: { timetable: { periods: PERIODS }, masterData: MASTER } });
|
||||
return new UntisClient(CONFIG).timetable('2026-09-19', '2026-09-21');
|
||||
};
|
||||
|
||||
it('keeps the reported clock time, which is local despite the Z', async () => {
|
||||
const table = await load();
|
||||
const monday = table.days.find((day) => day.date === '2026-09-21');
|
||||
assert.ok(monday);
|
||||
// 08:00Z must stay 08:00: the school's first lesson starts at eight.
|
||||
assert.equal(monday.lessons[0]?.start, '08:00');
|
||||
assert.equal(monday.lessons[0]?.end, '08:45');
|
||||
});
|
||||
|
||||
it('includes days without lessons, with the holiday that explains them', async () => {
|
||||
const table = await load();
|
||||
assert.deepEqual(
|
||||
table.days.map((day) => day.date),
|
||||
['2026-09-19', '2026-09-20', '2026-09-21'],
|
||||
);
|
||||
const sunday = table.days.find((day) => day.date === '2026-09-20');
|
||||
assert.equal(sunday?.lessons.length, 0);
|
||||
assert.equal(sunday?.holidays[0]?.name, 'Weltkindertag');
|
||||
assert.equal(table.days.find((day) => day.date === '2026-09-19')?.holidays.length, 0);
|
||||
});
|
||||
|
||||
it('resolves subjects, teachers, rooms and classes to their names', async () => {
|
||||
const lesson = (await load()).days.at(-1)?.lessons[0];
|
||||
assert.deepEqual(lesson?.subjects, [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }]);
|
||||
assert.deepEqual(lesson?.teachers, [{ name: 'Ra', longName: 'Kristin Rammelt' }]);
|
||||
assert.deepEqual(lesson?.rooms, [{ name: '42', longName: 'Raum 42' }]);
|
||||
assert.deepEqual(lesson?.classes, [{ name: 'FIA24B', longName: 'Fachinformatiker' }]);
|
||||
assert.equal(lesson?.notes.info, 'Test Projektdoku');
|
||||
assert.equal(lesson?.homework[0]?.due, '2026-09-21');
|
||||
assert.equal(lesson?.periodId, 100);
|
||||
});
|
||||
|
||||
it('marks a cancellation and its replacement, including what was replaced', async () => {
|
||||
const lessons = (await load()).days.at(-1)!.lessons;
|
||||
const cancelled = lessons.find((lesson) => lesson.periodId === 101);
|
||||
const replacement = lessons.find((lesson) => lesson.periodId === 102);
|
||||
assert.equal(cancelled?.cancelled, true);
|
||||
assert.equal(cancelled?.changed, false);
|
||||
assert.equal(replacement?.changed, true);
|
||||
assert.equal(replacement?.cancelled, false);
|
||||
assert.equal(replacement?.notes.substitution, 'Unterricht vom 30.09.2026');
|
||||
// The room carries an orgId of its own: the lesson is in 42 instead of 001,
|
||||
// and `replaced` is the room it moved out of.
|
||||
assert.deepEqual(replacement?.rooms, [{ name: '42', longName: 'Raum 42' }]);
|
||||
assert.deepEqual(replacement?.replaced.rooms, [{ name: '001', longName: 'GR - Praxis' }]);
|
||||
});
|
||||
|
||||
it('sorts a day by clock time', async () => {
|
||||
const starts = (await load()).days.at(-1)!.lessons.map((lesson) => lesson.start);
|
||||
assert.deepEqual(starts, ['08:00', '10:05', '10:05']);
|
||||
});
|
||||
|
||||
it('throws on a timestamp it cannot read rather than dropping the lesson', () => {
|
||||
assert.throws(() => splitLocal('21.09.2026 08:00'), /unexpected format/);
|
||||
assert.deepEqual(splitLocal('2026-09-21T08:00Z'), { date: '2026-09-21', time: '08:00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('UntisClient: homework and lesson topics', () => {
|
||||
it('resolves a homework subject through its lesson', async () => {
|
||||
stub({
|
||||
getUserData2017: USER_DATA,
|
||||
getHomeWork2017: {
|
||||
homeWorks: [
|
||||
{
|
||||
id: 16_404,
|
||||
lessonId: 71_296,
|
||||
startDate: '2026-08-24',
|
||||
endDate: '2026-08-31',
|
||||
text: 'Material Plakat Kohlbergs Stufenmodell',
|
||||
remark: null,
|
||||
completed: false,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
lessonsById: { '71296': { id: 71_296, subjectId: 130 } },
|
||||
},
|
||||
});
|
||||
const [item] = await new UntisClient(CONFIG).homework('2026-08-01', '2026-09-30');
|
||||
assert.equal(item?.subject?.name, 'Eth');
|
||||
assert.equal(item?.due, '2026-08-31');
|
||||
assert.equal(item?.assigned, '2026-08-24');
|
||||
assert.equal(item?.completed, false);
|
||||
});
|
||||
|
||||
it('reads what previous lessons covered, dropping the empty entries', async () => {
|
||||
stub({
|
||||
getUserData2017: USER_DATA,
|
||||
getLessonTopic2017: {
|
||||
previousTopics: [
|
||||
{ text: 'Projektplanung und Risikoanalyse', periodId: 5, startDateTime: '2026-09-02T11:45Z', endDateTime: '2026-09-02T12:30Z' },
|
||||
{ text: ' ', periodId: 6, startDateTime: '2026-09-01T11:45Z', endDateTime: '2026-09-01T12:30Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const topics = await new UntisClient(CONFIG).lessonTopics(100);
|
||||
assert.equal(topics.length, 1);
|
||||
assert.deepEqual(topics[0], {
|
||||
text: 'Projektplanung und Risikoanalyse',
|
||||
periodId: 5,
|
||||
date: '2026-09-02',
|
||||
start: '11:45',
|
||||
end: '12:30',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTimetable', () => {
|
||||
/** Only the two methods the renderer uses, so the formatting is what is tested. */
|
||||
const fake = (days: UntisTimetable['days'], messages: { subject: string; text: string }[] = []) =>
|
||||
({
|
||||
timetable: async (from: string, to: string) => ({
|
||||
from,
|
||||
to,
|
||||
days: days.filter((day) => day.date >= from && day.date <= to),
|
||||
}),
|
||||
messagesOfDay: async () => messages,
|
||||
}) as unknown as UntisClientType;
|
||||
|
||||
const lesson = (over: Partial<UntisTimetable['days'][number]['lessons'][number]> = {}) => ({
|
||||
periodId: 100,
|
||||
lessonId: 900,
|
||||
date: '2026-09-21',
|
||||
start: '08:00',
|
||||
end: '08:45',
|
||||
statuses: ['REGULAR'],
|
||||
cancelled: false,
|
||||
changed: false,
|
||||
subjects: [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }],
|
||||
teachers: [{ name: 'Ra', longName: 'Kristin Rammelt' }],
|
||||
rooms: [{ name: '42' }],
|
||||
classes: [],
|
||||
replaced: { subjects: [], teachers: [], rooms: [] },
|
||||
notes: {},
|
||||
homework: [],
|
||||
online: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('renders a day with its weekday, subject, room, teacher and period id', async () => {
|
||||
const out = await readTimetable(fake([{ date: '2026-09-21', lessons: [lesson()], holidays: [] }]), {
|
||||
from: '2026-09-21',
|
||||
to: '2026-09-21',
|
||||
});
|
||||
assert.match(out, /## Montag, 21\.09\.2026/);
|
||||
assert.match(out, /08:00–08:45 \*\*IT-FIA\*\* \(Fachtheorie FIA\) · Raum 42 · Ra \(Kristin Rammelt\)/);
|
||||
assert.match(out, /`100`/);
|
||||
});
|
||||
|
||||
it('says Entfall, and names who a Vertretung stands in for', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 101, cancelled: true, statuses: ['CANCELLED'] }),
|
||||
lesson({
|
||||
periodId: 102,
|
||||
changed: true,
|
||||
statuses: ['IRREGULAR'],
|
||||
teachers: [{ name: 'Hy', longName: 'Justus Hoyme' }],
|
||||
notes: { substitution: 'Unterricht vom 30.09.2026' },
|
||||
}),
|
||||
];
|
||||
const out = await readTimetable(fake([{ date: '2026-09-21', lessons, holidays: [] }]), {
|
||||
from: '2026-09-21',
|
||||
to: '2026-09-21',
|
||||
});
|
||||
assert.match(out, /\*\*Entfall\*\*/);
|
||||
assert.match(out, /\*\*Vertretung\*\* \(statt Ra\)/);
|
||||
assert.match(out, /Vertretungstext: Unterricht vom 30\.09\.2026/);
|
||||
});
|
||||
|
||||
it('explains an empty day and points at the next one with lessons', async () => {
|
||||
const out = await readTimetable(
|
||||
fake([
|
||||
{ date: '2026-09-17', lessons: [], holidays: [] },
|
||||
{ date: '2026-09-21', lessons: [lesson()], holidays: [] },
|
||||
]),
|
||||
{ from: '2026-09-17', to: '2026-09-17' },
|
||||
);
|
||||
assert.match(out, /No lessons\. Not a holiday either/);
|
||||
assert.match(out, /\*\*Next lessons:\*\* Montag, 21\.09\.2026 \(1 lesson\(s\)\)/);
|
||||
});
|
||||
|
||||
it('names the holiday when there is one, instead of guessing', async () => {
|
||||
const out = await readTimetable(
|
||||
fake([
|
||||
{
|
||||
date: '2026-10-13',
|
||||
lessons: [],
|
||||
holidays: [{ name: 'Herbstferien', longName: 'Herbstferien (12.10.-23.10.)', start: '2026-10-12', end: '2026-10-23' }],
|
||||
},
|
||||
]),
|
||||
{ from: '2026-10-13', to: '2026-10-13' },
|
||||
);
|
||||
assert.match(out, /Herbstferien \(12\.10\.-23\.10\.\)/);
|
||||
assert.match(out, /_No lessons\._/);
|
||||
assert.doesNotMatch(out, /company phase/);
|
||||
});
|
||||
|
||||
it('with changesOnly lists only changed days, and says so when none changed', async () => {
|
||||
const days = [
|
||||
{ date: '2026-09-21', lessons: [lesson()], holidays: [] },
|
||||
{ date: '2026-09-22', lessons: [lesson({ date: '2026-09-22', periodId: 103, cancelled: true })], holidays: [] },
|
||||
];
|
||||
const out = await readTimetable(fake(days), { from: '2026-09-21', to: '2026-09-22', changesOnly: true });
|
||||
assert.match(out, /Dienstag, 22\.09\.2026/);
|
||||
assert.doesNotMatch(out, /Montag, 21\.09\.2026/);
|
||||
|
||||
const quiet = await readTimetable(fake([days[0]!]), { from: '2026-09-21', to: '2026-09-21', changesOnly: true });
|
||||
assert.match(quiet, /Nothing cancelled or changed/);
|
||||
// The next-lessons hint is about lessons, so it has no place here.
|
||||
assert.doesNotMatch(quiet, /Next lessons/);
|
||||
});
|
||||
|
||||
it('appends the Nachrichten des Tages for a single day only', async () => {
|
||||
const day = [{ date: '2026-09-21', lessons: [lesson()], holidays: [] }];
|
||||
const messages = [{ subject: 'Hitzefrei', text: 'Unterrichtsschluss 12:30' }];
|
||||
const single = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-21' });
|
||||
assert.match(single, /Nachrichten des Tages/);
|
||||
assert.match(single, /Hitzefrei: Unterrichtsschluss 12:30/);
|
||||
|
||||
const range = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-25' });
|
||||
assert.doesNotMatch(range, /Nachrichten des Tages/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user