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): { 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 => { 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 = {}) => ({ 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/); }); });