The account this was built against is in no rooms, so the whole space was invisible and easy to dismiss as an empty endpoint. It is not empty in general — the user had rooms until a teacher removed access — and the UI's naming actively hides the distinction: the sidebar's *Kurse* entry links to `/rooms/courses-overview` and lists courses, while *Räume* links to `/rooms` and lists rooms. A url containing `/rooms` identifies neither. list_rooms and get_room cover the latter. A room holds boards and nothing else, so get_room lists boards for get_board (which already reports "in room" from the board context) plus who else is in it. Room boards report `isVisible`, which the course-page projection does not, so a draft is named as a draft instead of being offered and then answering 403. Rooms also go through the crawl, or they would have become the next blind spot: their boards are indexed, searchable by both the index and the live-crawl path, diffed by what_changed, and mirrored by the CLI under the room's name. The board traversal and the snapshot matcher are now shared between courses and rooms rather than duplicated, which also fixed the live-crawl path silently not searching pad contents. The CLI needed no new command — it is file-centric and inherits rooms through the manifest — but `--course` now accepts a room id, and says so. `kind` gains 'room'; the column is plain TEXT, so no migration. 112 tests. Smoke: 42/42 and 44/44 local, 41/41 and 43/43 live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
406 lines
16 KiB
JavaScript
Executable File
406 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Simulate a teacher's work against the LOCAL instance: create, change and
|
|
* delete the things teachers actually create, so the MCP server can be exercised
|
|
* against content the real account has never contained.
|
|
*
|
|
* This is the only thing in this repository that writes to a Schulcloud, and the
|
|
* guard below is what keeps it that way. The MCP server itself stays read-only;
|
|
* nothing here runs through it.
|
|
*
|
|
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
|
|
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
|
|
* node scripts/simulate-teacher.mjs delete # remove it again
|
|
*
|
|
* State lives in .simulate-teacher.json so the phases can be run one at a time
|
|
* with MCP checks in between.
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const STATE = join(ROOT, '.simulate-teacher.json');
|
|
|
|
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
|
|
// routed through the ingress (the live deployment does the same), and the
|
|
// legacy services are the only way to create tasks and topics.
|
|
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
|
|
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
|
|
|
|
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
|
|
const { hostname } = new URL(url);
|
|
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
|
|
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
|
|
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
|
|
/** Everything is created here, because the demo student is a member. */
|
|
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
|
|
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
|
|
|
|
let jwt = '';
|
|
let me;
|
|
|
|
async function req(base, method, path, body, { raw = false } = {}) {
|
|
const headers = { Authorization: `Bearer ${jwt}` };
|
|
let payload;
|
|
if (body instanceof FormData) {
|
|
payload = body;
|
|
} else if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
payload = JSON.stringify(body);
|
|
}
|
|
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
|
}
|
|
if (raw) return text;
|
|
return text ? JSON.parse(text) : undefined;
|
|
}
|
|
|
|
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
|
|
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
|
|
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
|
|
|
|
const log = (...a) => console.log(' ', ...a);
|
|
const step = (s) => console.log(`\n== ${s} ==`);
|
|
|
|
function loadState() {
|
|
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
|
|
}
|
|
function saveState(s) {
|
|
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
|
|
}
|
|
|
|
async function login() {
|
|
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
|
|
});
|
|
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
|
|
jwt = (await res.json()).accessToken;
|
|
me = await v3('GET', '/me');
|
|
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
|
|
}
|
|
|
|
/** The demo student, so created content is visible to the account under test. */
|
|
async function studentId() {
|
|
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
|
|
const user = found.data?.[0] ?? found[0];
|
|
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
|
|
return user._id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- create ---
|
|
|
|
async function create() {
|
|
const s = loadState();
|
|
// Saved after every step, not at the end: a failure halfway through used to
|
|
// leave created objects with no record of their ids, and so no way to delete
|
|
// them again.
|
|
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
|
|
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
const student = await studentId();
|
|
|
|
step('course');
|
|
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
|
|
keep('courseId', course.courseId ?? course.id ?? course._id);
|
|
// v3 creates the course but takes no members; the legacy service does.
|
|
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
|
|
log(`course ${s.courseId} with the demo student enrolled`);
|
|
|
|
step('room');
|
|
const room = await v3('POST', '/rooms', {
|
|
name: `MCP-Test Raum (${stamp})`,
|
|
color: 'blue',
|
|
features: [],
|
|
});
|
|
keep('roomId', room.id);
|
|
log(`room ${s.roomId}`);
|
|
|
|
step('room contents');
|
|
// Rooms are the newer collaboration space, separate from courses: a room has
|
|
// its own members and its own boards, and appears under "Räume" in the UI
|
|
// while courses appear under "Kurse" at a /rooms/... url.
|
|
await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] });
|
|
log('demo student added as a room member');
|
|
|
|
const roomBoard = await v3('POST', '/boards', {
|
|
title: `MCP-Test Raum-Board (${stamp})`,
|
|
parentId: s.roomId,
|
|
parentType: 'room',
|
|
layout: 'columns',
|
|
});
|
|
keep('roomBoardId', roomBoard.id);
|
|
const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`);
|
|
await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' });
|
|
const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`);
|
|
await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' });
|
|
const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' });
|
|
await v3('PATCH', `/elements/${roomText.id}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
// A file in a room board, so the mirror path (room name, not course name) and
|
|
// the CLI manifest are exercised for rooms too.
|
|
const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' });
|
|
keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain',
|
|
'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n'));
|
|
|
|
await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true });
|
|
log(`room board ${s.roomBoardId} published, with one card`);
|
|
|
|
// A second room the student is NOT in, so "only rooms I belong to" is testable.
|
|
const other = await v3('POST', '/rooms', {
|
|
name: `MCP-Test Raum ohne Zugriff (${stamp})`,
|
|
color: 'red',
|
|
features: [],
|
|
});
|
|
keep('roomWithoutStudentId', other.id);
|
|
log(`second room ${other.id} left without the student on purpose`);
|
|
|
|
step('team');
|
|
// Teams cannot be created through the API: the legacy service registers
|
|
// ['find','get','update','patch','remove'] and no 'create'
|
|
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
|
|
// beyond news and create-room. POST /teams answers 405. So the simulation
|
|
// adopts a seeded team and edits that instead — which is all the MCP server
|
|
// needs, since it reads teams only through api_get.
|
|
const teams = await v1('GET', '/teams');
|
|
const existing = (teams.data ?? [])[0];
|
|
if (existing) {
|
|
keep('teamId', existing._id);
|
|
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
|
|
} else {
|
|
log('no seeded team to adopt; skipping');
|
|
}
|
|
|
|
step('topic (legacy lesson) in Biologie 9b');
|
|
const lesson = await v1('POST', '/lessons', {
|
|
name: `MCP-Test Thema (${stamp})`,
|
|
courseId: COURSE,
|
|
hidden: false,
|
|
contents: [
|
|
{
|
|
title: 'Einführung',
|
|
hidden: false,
|
|
component: 'text',
|
|
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
|
|
},
|
|
],
|
|
});
|
|
keep('lessonId', lesson._id);
|
|
log(`lesson ${s.lessonId}`);
|
|
|
|
step('task (homework) in Biologie 9b');
|
|
const task = await v1('POST', '/homework', {
|
|
name: `MCP-Test Aufgabe (${stamp})`,
|
|
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
|
|
courseId: COURSE,
|
|
availableDate: new Date(Date.now() - 86400_000).toISOString(),
|
|
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
|
private: false,
|
|
teacherId: me.user.id,
|
|
schoolId: me.school.id,
|
|
});
|
|
keep('taskId', task._id);
|
|
log(`task ${s.taskId}`);
|
|
|
|
step('column board in Biologie 9b');
|
|
const board = await v3('POST', '/boards', {
|
|
title: `MCP-Test Board (${stamp})`,
|
|
parentId: COURSE,
|
|
parentType: 'course',
|
|
layout: 'columns',
|
|
});
|
|
keep('boardId', board.id);
|
|
const column = await v3('POST', `/boards/${s.boardId}/columns`);
|
|
keep('columnId', column.id);
|
|
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
|
|
const card = await v3('POST', `/columns/${s.columnId}/cards`);
|
|
keep('cardId', card.id);
|
|
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
|
|
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
|
|
|
|
step('card elements');
|
|
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
|
|
keep('richTextId', rich.id);
|
|
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
log(`richText ${s.richTextId}`);
|
|
|
|
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
|
|
keep('linkId', link.id);
|
|
await v3('PATCH', `/elements/${s.linkId}/content`, {
|
|
data: {
|
|
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
|
|
type: 'link',
|
|
},
|
|
});
|
|
log(`link ${s.linkId}`);
|
|
|
|
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
|
|
keep('padElementId', pad.id);
|
|
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
|
|
|
|
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
|
|
keep('folderId', folder.id);
|
|
await v3('PATCH', `/elements/${s.folderId}/content`, {
|
|
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
|
|
});
|
|
log(`fileFolder (directory) ${s.folderId}`);
|
|
|
|
step('files');
|
|
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
|
|
keep('fileElementId', fileEl.id);
|
|
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
|
|
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
|
|
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
|
|
|
|
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
|
|
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
|
|
log(`file ${s.folderFileId} inside the directory`);
|
|
|
|
step('publishing');
|
|
// A board is created as a draft. Students get 403 on it while the course page
|
|
// still lists its title, so the fixture needs both states to be useful.
|
|
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
|
|
log('main board published');
|
|
|
|
const draft = await v3('POST', '/boards', {
|
|
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
|
|
parentId: COURSE,
|
|
parentType: 'course',
|
|
layout: 'columns',
|
|
});
|
|
keep('draftBoardId', draft.id);
|
|
log(`second board left unpublished on purpose: ${draft.id}`);
|
|
|
|
saveState(s);
|
|
console.log(`\nstate written to ${STATE}`);
|
|
summary(s);
|
|
}
|
|
|
|
/** files-storage attaches bytes to a board node (element) id, not to the card. */
|
|
async function upload(parentId, name, type, body) {
|
|
const form = new FormData();
|
|
form.append('file', new Blob([body], { type }), name);
|
|
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
|
|
return record.id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update ---
|
|
|
|
async function update() {
|
|
const s = loadState();
|
|
if (!s.boardId) throw new Error('nothing to update — run `create` first');
|
|
const stamp = new Date().toISOString().slice(11, 16);
|
|
|
|
step('renames');
|
|
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
|
|
log('course renamed');
|
|
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
|
|
log('board renamed');
|
|
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
|
|
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
|
|
log('column and card renamed');
|
|
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
|
|
log('task renamed');
|
|
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
|
|
log('topic renamed');
|
|
if (s.teamId) {
|
|
const team = await v1('GET', `/teams/${s.teamId}`);
|
|
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
|
|
log('team renamed (the one write teams do allow)');
|
|
}
|
|
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
|
|
log('room renamed');
|
|
|
|
step('content edits');
|
|
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
|
|
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
|
|
log('file renamed');
|
|
|
|
saveState({ ...s, updated: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------- delete ---
|
|
|
|
async function remove() {
|
|
const s = loadState();
|
|
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
|
|
|
|
step('deleting what was created');
|
|
const tries = [
|
|
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
|
|
['room file', () => files('DELETE', `/delete/${s.roomFileId}`)],
|
|
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
|
|
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
|
|
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
|
|
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
|
|
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
|
|
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
|
|
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
|
|
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
|
|
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
|
|
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
|
|
['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)],
|
|
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
|
|
['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)],
|
|
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
|
|
];
|
|
for (const [what, fn] of tries) {
|
|
try {
|
|
await fn();
|
|
log(`deleted ${what}`);
|
|
} catch (err) {
|
|
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
|
|
}
|
|
}
|
|
unlinkSync(STATE);
|
|
console.log(`\nstate file removed`);
|
|
}
|
|
|
|
function summary(s) {
|
|
console.log('\nids for the MCP side:');
|
|
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
|
|
}
|
|
|
|
const phase = process.argv[2] ?? 'create';
|
|
await login();
|
|
if (phase === 'create') await create();
|
|
else if (phase === 'update') await update();
|
|
else if (phase === 'delete') await remove();
|
|
else if (phase === 'show') summary(loadState());
|
|
else {
|
|
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
|
|
process.exit(2);
|
|
}
|