Prepare for a live account: separate the indexes, and probe new routes live
With .env pointing at the real account, testing against the local instance became dangerous: process env beats --env-file, so a local smoke run would crawl fixtures straight into the live index, where a per-course refresh carries them forward indefinitely. mcp-env.sh now pins its own database (schulcloud_local) and mirror as well as the instance. The override publishes the server on MCP_HOST_PORT and takes CRAWL_INTERVAL_MS from .env, since what_changed can only report what happened between crawls. The build context leaves out tmp/, which holds a mirror of the account's files, and local-instance/. probe checks live what the gap fixes established locally: the /api/v1 course and user routes, classes, room allowedOperations as an object, and the preview enums on a real file. A failure there means a feature degrades rather than breaks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,3 +11,8 @@ scripts
|
|||||||
!scripts/copy-assets.mjs
|
!scripts/copy-assets.mjs
|
||||||
docs
|
docs
|
||||||
files.zip
|
files.zip
|
||||||
|
# Mirrored coursework and server logs: never needed to build, and the mirror
|
||||||
|
# holds the account's files, which have no business in a build context.
|
||||||
|
tmp
|
||||||
|
# The local test instance has its own compose project.
|
||||||
|
local-instance
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ MCP_AUTH_TOKEN=
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
BIND_HOST=0.0.0.0
|
BIND_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Local Docker only: the loopback port docker-compose.override.yml publishes the
|
||||||
|
# container on. Default 8080; change it when that port is already taken.
|
||||||
|
# MCP_HOST_PORT=8080
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Index and file mirror (optional — without these the server runs live-only:
|
# Index and file mirror (optional — without these the server runs live-only:
|
||||||
# search crawls on every call, and the CLI's /api surface is unavailable)
|
# search crawls on every call, and the CLI's /api surface is unavailable)
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ npm run session-diagnose # ~2.5h: measure what actually ends the session
|
|||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.override.yml` is local-only and publishes the server on
|
`docker-compose.override.yml` is local-only and publishes the server on
|
||||||
`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`.
|
`127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see
|
||||||
|
`docs/LOCAL.md`. When `.env` points at the live account, test against the local
|
||||||
|
instance only through `local-instance/scripts/mcp-env.sh`: it pins its own
|
||||||
|
database (`schulcloud_local`) and mirror, so fixtures cannot reach the live
|
||||||
|
index.
|
||||||
|
|
||||||
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
||||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# The bundled postgres service, reachable by name on the compose network.
|
# The bundled postgres service, reachable by name on the compose network.
|
||||||
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
|
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
|
||||||
# Crawl on demand while developing rather than every 6 hours.
|
# On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
|
||||||
CRAWL_INTERVAL_MS: 0
|
# crawl on a timer instead — what_changed can only report what happened
|
||||||
|
# between crawls, so against a real account on-demand leaves it empty.
|
||||||
|
CRAWL_INTERVAL_MS: ${CRAWL_INTERVAL_MS:-0}
|
||||||
ports:
|
ports:
|
||||||
# Bound to loopback: this exposes the account's data, and the bearer token
|
# Bound to loopback: this exposes the account's data, and the bearer token
|
||||||
# is the only thing in front of it.
|
# is the only thing in front of it.
|
||||||
- "127.0.0.1:8080:8080"
|
# MCP_HOST_PORT moves it when 8080 is already taken on this machine.
|
||||||
|
- "127.0.0.1:${MCP_HOST_PORT:-8080}:8080"
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":
|
|||||||
|
|
||||||
`docker-compose.override.yml` is merged automatically and is **local-only**: it
|
`docker-compose.override.yml` is merged automatically and is **local-only**: it
|
||||||
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
|
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
|
||||||
switches crawling to on-demand. On the Pi neither port is published — Caddy
|
switches crawling to on-demand. Two `.env` settings adjust it: `MCP_HOST_PORT`
|
||||||
|
moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
|
||||||
|
crawl timer — worth doing against a real account, because `what_changed` can
|
||||||
|
only report what happened between crawls.
|
||||||
|
|
||||||
|
After pasting a new `TSC_JWT_COOKIE` into `.env`, **recreate** the container —
|
||||||
|
`env_file` is read when the container is created, not on restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate schulcloud-mcp
|
||||||
|
``` On the Pi neither port is published — Caddy
|
||||||
reaches the container over the Docker network.
|
reaches the container over the Docker network.
|
||||||
|
|
||||||
Loopback binding is deliberate. The bearer token is the only thing in front of
|
Loopback binding is deliberate. The bearer token is the only thing in front of
|
||||||
|
|||||||
@@ -193,6 +193,21 @@ eval "$(./scripts/mcp-env.sh)" # as the demo student
|
|||||||
cd .. && npm run smoke # 39 checks against the local instance
|
cd .. && npm run smoke # 39 checks against the local instance
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
||||||
|
mirror at `tmp/mirror-local` — not just the instance at this one. The root
|
||||||
|
`.env` normally targets the live account, and process env beats `--env-file`, so
|
||||||
|
without that a local smoke run would crawl these fixtures into the live index,
|
||||||
|
where a per-course refresh then carries them forward indefinitely. Create the
|
||||||
|
database once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
|
||||||
|
-c "CREATE DATABASE schulcloud_local OWNER schulcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
Without it the server does not fail; it runs live-only, and smoke reports the
|
||||||
|
smaller, index-free check count.
|
||||||
|
|
||||||
It also builds a **room** ("Raum"): rooms are a separate space from courses,
|
It also builds a **room** ("Raum"): rooms are a separate space from courses,
|
||||||
and the naming misleads — the sidebar's *Kurse* entry points at
|
and the naming misleads — the sidebar's *Kurse* entry points at
|
||||||
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
|
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
|
||||||
|
|||||||
@@ -23,8 +23,19 @@ token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
|
|||||||
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
|
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
|
||||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
|
||||||
|
|
||||||
|
# The index and mirror are pinned too, not just the instance. The repo's .env
|
||||||
|
# normally points at the live account, and process env beats --env-file, so
|
||||||
|
# without these a local smoke run would crawl this throwaway instance straight
|
||||||
|
# into the live index — and a per-course refresh carries everything outside its
|
||||||
|
# scope forward, so the fixtures would outlive the run. Fixtures in real data
|
||||||
|
# is exactly the accident the store tests' "test" guard exists to prevent.
|
||||||
|
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
cat <<ENV
|
cat <<ENV
|
||||||
export TSC_URL=$URL
|
export TSC_URL=$URL
|
||||||
export TSC_JWT_COOKIE=$token
|
export TSC_JWT_COOKIE=$token
|
||||||
export MCP_AUTH_TOKEN=local-instance-token
|
export MCP_AUTH_TOKEN=local-instance-token
|
||||||
|
export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local
|
||||||
|
export MIRROR_DIR=$ROOT/tmp/mirror-local
|
||||||
|
export INDEX_PERSONAL_FILES=true
|
||||||
ENV
|
ENV
|
||||||
|
|||||||
@@ -124,6 +124,84 @@ if (me) {
|
|||||||
const lesson = await client.getLesson(lessonId);
|
const lesson = await client.getLesson(lessonId);
|
||||||
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
|
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- assumptions first established against the local 33.40 instance ----
|
||||||
|
// Each of these was verified locally and against the deployment's ingress
|
||||||
|
// table, not against this instance. A FAIL here means the matching feature
|
||||||
|
// degrades (to bare ids, to no timetable, to no preview) rather than breaks.
|
||||||
|
console.log('\nlegacy routes, classes, rooms:');
|
||||||
|
const firstCourse = courses[0];
|
||||||
|
if (firstCourse) {
|
||||||
|
await expect('GET /api/v1/courses/{id} (description, teachers, timetable)', async () => {
|
||||||
|
const legacy = await client.getLegacyCourse(firstCourse.id);
|
||||||
|
const parts = [
|
||||||
|
legacy.description ? 'description' : null,
|
||||||
|
legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null,
|
||||||
|
legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => {
|
||||||
|
const user = await client.getLegacyUser(me.user.id);
|
||||||
|
return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name';
|
||||||
|
});
|
||||||
|
const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined;
|
||||||
|
if (teacherId) {
|
||||||
|
// Expected to be refused for a student: names then degrade to "not
|
||||||
|
// visible to this account". Reported, not failed, either way.
|
||||||
|
const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`);
|
||||||
|
console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`);
|
||||||
|
}
|
||||||
|
await expect('GET /api/v3/groups/class', async () => {
|
||||||
|
const classes = await client.listClasses();
|
||||||
|
const named = classes.filter((entry) => entry.teacherNames?.length).length;
|
||||||
|
return `${classes.length} class(es), ${named} with teacher names`;
|
||||||
|
});
|
||||||
|
const rooms = await client.listRooms().catch(() => []);
|
||||||
|
if (rooms[0]) {
|
||||||
|
await expect('room allowedOperations is an object, not a list', async () => {
|
||||||
|
const room = await client.getRoom(rooms[0].id);
|
||||||
|
const ops = room.allowedOperations;
|
||||||
|
if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts');
|
||||||
|
return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' — no room to check allowedOperations against (in none is normal)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview route is the answer for image-only PDFs, and it has two
|
||||||
|
// undocumented enums. Only testable with a file whose preview is possible.
|
||||||
|
if (boardId) {
|
||||||
|
const skeleton = await client.getBoardSkeleton(boardId);
|
||||||
|
const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20));
|
||||||
|
const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder');
|
||||||
|
let previewed = false;
|
||||||
|
for (const element of pdfElement) {
|
||||||
|
const page = await client
|
||||||
|
.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id })
|
||||||
|
.catch(() => undefined);
|
||||||
|
const record = page?.data.find((file) => file.previewStatus === 'preview_possible');
|
||||||
|
if (!record) continue;
|
||||||
|
await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => {
|
||||||
|
const preview = await client.getFilePreview(record, 500);
|
||||||
|
return `${preview.mimeType}, ${preview.bytes.length} bytes`;
|
||||||
|
});
|
||||||
|
previewed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!previewed) console.log(' — no previewable file among the sampled cards');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expect(label, run) {
|
||||||
|
try {
|
||||||
|
const detail = await run();
|
||||||
|
console.log(` ok ${label}${detail ? ` (${detail})` : ''}`);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
||||||
|
console.log(` FAIL ${label} → ${status} ${String(error.message).slice(0, 120)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarize(result) {
|
function summarize(result) {
|
||||||
|
|||||||
Reference in New Issue
Block a user