Files
Schulcloud-MCP/test/client.test.ts
MechaCat02 634b004985 Fix get_board on boards with more than 20 cards
GET /api/v3/cards?ids= accepts at most 20 ids. Above that the request
fails with 400 "each value in ids must be a mongodb id" — which blames
the ids when the real problem is how many there are. Express/NestJS
parse the query string with qs, whose default arrayLimit is 20; past it
the repeated params stop being an array and become an object keyed "0",
"1", …, and @IsMongoId({ each: true }) then rejects every value.

I had chunked at 40, having read the controller and its DTO and found no
documented ceiling. The limit is not there — it is in the query parser
underneath them, which I did not think to check. Verified live: 20 ids
return 200, 21 return 400 with identical ids.

The worse half of this was mine alone. The crawler caught assembleBoard
failures and dropped them, so every board over 20 cards vanished from
the index while the crawl reported "failures: none". Board errors now go
into Snapshot.failures and are surfaced by refresh_index.

Impact of both fixes on a full re-crawl: 205 files -> 255, and the
reported board (27 cards, 18 files) reads fully. The two failures that
remain are genuine 403s — boards this account cannot see — and are now
visible rather than silent.

Thanks to the bug report, which had the root cause exactly right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:37:47 +02:00

112 lines
3.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { SchulcloudClient, SchulcloudApiError } from '../src/core/client.ts';
/**
* Retry behaviour, driven by a stubbed fetch. Observed live: a full crawl made
* the instance answer 4 of 26 course pages with a 503 front-page, all of which
* succeeded on retry — so this is the difference between a complete index and a
* quietly incomplete one.
*/
const config = {
baseUrl: 'https://example.test',
jwt: 'x',
authToken: undefined,
port: 1,
bindHost: '127.0.0.1',
maxDownloadBytes: 1000,
maxExtractedChars: 1000,
requestTimeoutMs: 1000,
keepaliveIntervalMs: 0,
databaseUrl: undefined,
mirrorDir: '/tmp',
mirrorMaxBytes: 1000,
crawlIntervalMs: 0,
};
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
function stubFetch(responses: (Response | Error)[]): () => number {
let calls = 0;
globalThis.fetch = (async () => {
const next = responses[calls++] ?? responses.at(-1)!;
if (next instanceof Error) throw next;
return next;
}) as typeof fetch;
return () => calls;
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
describe('SchulcloudClient retries', () => {
it('retries a 503 and succeeds', async () => {
const calls = stubFetch([json({}, 503), json({ school: { id: 's' } })]);
const client = new SchulcloudClient(config);
await client.me();
assert.equal(calls(), 2);
});
it('retries transient network errors', async () => {
const calls = stubFetch([new Error('ECONNRESET'), json({ school: { id: 's' } })]);
await new SchulcloudClient(config).me();
assert.equal(calls(), 2);
});
it('does not retry a 401 — a dead token will not recover', async () => {
const calls = stubFetch([json({}, 401)]);
await assert.rejects(() => new SchulcloudClient(config).me(), (error: SchulcloudApiError) => {
assert.equal(error.status, 401);
return true;
});
assert.equal(calls(), 1, 'retrying an expired token just wastes time');
});
it('does not retry a 404', async () => {
const calls = stubFetch([json({}, 404)]);
await assert.rejects(() => new SchulcloudClient(config).me());
assert.equal(calls(), 1);
});
it('gives up after the retry budget and reports the real status', async () => {
const calls = stubFetch([json({}, 503)]);
await assert.rejects(() => new SchulcloudClient(config).me(), (error: SchulcloudApiError) => {
assert.equal(error.status, 503);
return true;
});
assert.equal(calls(), 4, 'one attempt plus three retries');
});
});
describe('getCards chunking', () => {
it('never sends more than 20 ids in one request', async () => {
// The API's query parser turns >20 repeated params into an object, and
// validation then rejects every id with a message blaming the ids rather
// than their number. Verified live: 20 -> 200, 21 -> 400.
const seen: number[] = [];
globalThis.fetch = (async (url: string | URL) => {
const count = [...new globalThis.URL(String(url)).searchParams.getAll('ids')].length;
seen.push(count);
return json({ data: [] });
}) as typeof fetch;
const ids = Array.from({ length: 47 }, (_, i) => String(i).padStart(24, '0'));
await new SchulcloudClient(config).getCards(ids);
assert.deepEqual(seen, [20, 20, 7], 'should split 47 ids into 20/20/7');
assert.ok(Math.max(...seen) <= 20);
});
it('merges the chunked responses into one list', async () => {
let call = 0;
globalThis.fetch = (async () =>
json({ data: [{ id: `card${call++}`, height: 1, elements: [] }] })) as typeof fetch;
const ids = Array.from({ length: 25 }, (_, i) => String(i).padStart(24, '0'));
const cards = await new SchulcloudClient(config).getCards(ids);
assert.equal(cards.length, 2, 'one card from each of the two chunks');
});
});