Files
Schulcloud-MCP/test/untis.test.ts
MechaCat02 74b97bc4dc 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>
2026-09-17 20:43:24 +02:00

407 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:0008: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/);
});
});