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:
@@ -466,6 +466,91 @@ if (hasIndex) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== WebUntis ==');
|
||||
// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist
|
||||
// when a key is configured. Both halves are asserted: with a key the live
|
||||
// answers have to be shaped right, without one the tools must not be offered at
|
||||
// all — a tool that can only fail is worse than a missing one.
|
||||
const hasUntis = Boolean(config.untis);
|
||||
{
|
||||
const untisTools = names.filter((name) => name.startsWith('untis_'));
|
||||
check(
|
||||
`untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`,
|
||||
hasUntis
|
||||
? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable'
|
||||
: untisTools.length === 0,
|
||||
untisTools.join(', ') || 'none',
|
||||
);
|
||||
}
|
||||
if (hasUntis) {
|
||||
const identity = await call('whoami');
|
||||
check(
|
||||
'whoami reports the WebUntis identity',
|
||||
/- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text),
|
||||
identity.text.split('\n').find((line) => line.startsWith('- WebUntis')),
|
||||
);
|
||||
|
||||
const today = await call('untis_timetable');
|
||||
check(
|
||||
'untis_timetable answers for today',
|
||||
!today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text),
|
||||
today.text.split('\n')[0],
|
||||
);
|
||||
|
||||
// A four-week window: either it holds lessons or the days say why not. The
|
||||
// school year has gaps — holidays, and the weeks this account spends at work —
|
||||
// so requiring lessons would make the run fail on a correct answer.
|
||||
const start = new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10);
|
||||
const month = await call('untis_timetable', { from: start, to: end });
|
||||
const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}–\d{2}:\d{2} /gm)].length;
|
||||
check(
|
||||
'untis_timetable answers for a four-week range',
|
||||
!month.isError && (lessonLines > 0 || /No lessons/.test(month.text)),
|
||||
`${lessonLines} lesson line(s)`,
|
||||
);
|
||||
|
||||
const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true });
|
||||
check(
|
||||
'untis_timetable lists changes only',
|
||||
!changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)),
|
||||
changes.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const homework = await call('untis_homework', { from: '2026-08-01', to: end });
|
||||
check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]);
|
||||
|
||||
// Every lesson line carries its period id, which is the handle for the class
|
||||
// register. Without one there is nothing to ask about, so the check follows
|
||||
// the timetable's own output rather than a hard-coded id.
|
||||
const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]);
|
||||
if (Number.isInteger(periodId)) {
|
||||
const topics = await call('untis_lesson_topics', { periodId, limit: 3 });
|
||||
check(
|
||||
'untis_lesson_topics reads what previous lessons covered',
|
||||
!topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)),
|
||||
topics.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
|
||||
}
|
||||
|
||||
const unreal = await call('untis_timetable', { from: '2026-02-30' });
|
||||
check(
|
||||
'a date that does not exist is refused rather than rolled over',
|
||||
unreal.isError && /Not a date in the calendar/.test(unreal.text),
|
||||
unreal.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const huge = await call('untis_timetable', { from: start, to: '2027-06-30' });
|
||||
check(
|
||||
'an unreasonably long range is refused before it is fetched',
|
||||
huge.isError && /at most \d+ at a time/.test(huge.text),
|
||||
huge.text.split('\n')[0],
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
console.log('\n== api_get guard rails ==');
|
||||
check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError);
|
||||
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
|
||||
|
||||
Reference in New Issue
Block a user