Moves the reusable half into src/core/ (client, types, board, extract, text, keepalive) and the MCP half into src/mcp/. The layering was already clean — nothing in core imported app code or read process.env — so this is a move, not a redesign, and the smoke suite stayed the oracle throughout. The substantive part is core/crawl.ts. The course->board->card->element ->file traversal previously existed only inside tools/search.ts, and the indexer, what's-new diff and file mirror all need it. It now returns a typed Snapshot with breadcrumbs, sorted so two crawls of unchanged content compare equal. Metadata only: downloading and extracting bytes is an order of magnitude more expensive and only the indexer wants it. core/match.ts holds the keyword matching, which makes it testable without a network, and core/text.ts gains the fold/tokenize/snippet helpers (accent folding is not optional for German). search now finds strictly more than before — 5 hits vs 3 for "Datenschutz" — because the snapshot surfaces file-name matches the old streaming walk skipped. 34 unit tests and 30/30 smoke checks pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { SchulcloudApiError } from '../src/core/client.ts';
|
|
import { SessionKeepalive } from '../src/core/keepalive.ts';
|
|
|
|
/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */
|
|
function fakeClient(outcomes: (Error | 'ok')[]) {
|
|
const calls: number[] = [];
|
|
return {
|
|
calls,
|
|
client: {
|
|
extendSession: async () => {
|
|
const outcome = outcomes[calls.length] ?? 'ok';
|
|
calls.push(Date.now());
|
|
if (outcome !== 'ok') throw outcome;
|
|
return { expiresInSeconds: 7200 };
|
|
},
|
|
} as never,
|
|
};
|
|
}
|
|
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
|
|
|
describe('SessionKeepalive', () => {
|
|
it('pings immediately on start, so a bad token is noticed at boot', async () => {
|
|
const { client, calls } = fakeClient(['ok']);
|
|
const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {});
|
|
keepalive.start();
|
|
await settle();
|
|
assert.equal(calls.length, 1);
|
|
keepalive.stop();
|
|
});
|
|
|
|
it('keeps pinging on the interval', async () => {
|
|
const { client, calls } = fakeClient([]);
|
|
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
|
keepalive.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
keepalive.stop();
|
|
assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`);
|
|
});
|
|
|
|
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
|
|
const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
|
|
const { client, calls } = fakeClient([unauthorized]);
|
|
const messages: string[] = [];
|
|
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
|
|
keepalive.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
assert.equal(calls.length, 1, 'must not keep hammering a dead token');
|
|
assert.equal(messages.length, 1);
|
|
assert.match(messages[0]!, /fresh jwt cookie/);
|
|
keepalive.stop();
|
|
});
|
|
|
|
it('retries a transient failure instead of giving up', async () => {
|
|
const { client, calls } = fakeClient([new Error('ECONNRESET')]);
|
|
const messages: string[] = [];
|
|
const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message));
|
|
keepalive.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
keepalive.stop();
|
|
assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`);
|
|
assert.match(messages[0]!, /retrying in/);
|
|
});
|
|
|
|
it('stop() prevents any further pings', async () => {
|
|
const { client, calls } = fakeClient([]);
|
|
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
|
keepalive.start();
|
|
await settle();
|
|
keepalive.stop();
|
|
const seen = calls.length;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
assert.equal(calls.length, seen, 'no pings after stop()');
|
|
});
|
|
});
|
|
|
|
describe('SessionKeepalive logging', () => {
|
|
it('reports the remaining session budget on a successful extension', async () => {
|
|
const messages: string[] = [];
|
|
const client = { extendSession: async () => ({ expiresInSeconds: 7200 }) } as never;
|
|
const keepalive = new SessionKeepalive(client, 60_000, 1000, (m) => messages.push(m));
|
|
keepalive.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
keepalive.stop();
|
|
assert.equal(messages.length, 1);
|
|
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
|
|
});
|
|
});
|