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
|
||||
docs
|
||||
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
|
||||
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:
|
||||
# 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
|
||||
`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
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
|
||||
@@ -12,9 +12,12 @@ services:
|
||||
environment:
|
||||
# The bundled postgres service, reachable by name on the compose network.
|
||||
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
|
||||
# Crawl on demand while developing rather than every 6 hours.
|
||||
CRAWL_INTERVAL_MS: 0
|
||||
# On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
|
||||
# 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:
|
||||
# Bound to loopback: this exposes the account's data, and the bearer token
|
||||
# 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
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
`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,
|
||||
and the naming misleads — the sidebar's *Kurse* entry points at
|
||||
`/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()))')")" \
|
||||
| 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
|
||||
export TSC_URL=$URL
|
||||
export TSC_JWT_COOKIE=$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
|
||||
|
||||
@@ -124,6 +124,84 @@ if (me) {
|
||||
const lesson = await client.getLesson(lessonId);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user