Files
Schulcloud-MCP/test/store.test.ts
MechaCat02 521c21f7ae Reach tasks attached to topics, and read Etherpad pads
Testing against a local instance turned up four things the server was
getting wrong, all of them invisible against the live account because the
data that exposes them had never been produced there.

`GET /lessons/{id}/tasks` returns a bare array, not the `{data,total}`
envelope every sibling endpoint uses, so `.data` was undefined and a
topic's tasks silently vanished. Its items also carry no id at all —
`LessonLinkedTaskResponse` has no id property — which leaves a
topic-attached task unidentifiable: it is not a task element on the
course page, and once past due it is in neither task list. So its
submission, and its grade, could not be reached by any route. That is 18
of 60 tasks on the real account, now reachable: the ids come off the
legacy topic page, where each task is linked as `/homework/{id}`.

The types said `id: string` and `status: TaskStatus` on something that
has neither, which is what let this stay quiet; `LessonLinkedTask` and
`ResolvedTask` now say what is actually there.

Collaborative text editor elements come back with `content: {}`, and the
tool said their contents were unavailable. They are available: the
content-element endpoint returns the pad url *and* an Etherpad session
cookie, and the pad exports itself as text to whoever holds it. No API
key needed. Pads are now shown by get_board and indexed for search.

The store's file digest covered id and size on the grounds that file
records are immutable. `PATCH /file/rename/{id}` renames one in place,
so a rename was reported as nothing at all.

Finally, get_board reported an unpublished board as "no permission",
which sends the reader hunting for an access problem that is not there.

smoke gains checks for topic tasks and for pads, and no longer assumes a
populated index or a search term that happens to match. 39/39 live-only
and 41/41 index-backed, against both the live instance and a local one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:39:51 +02:00

168 lines
6.9 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { Store } from '../src/store/store.ts';
import type { Snapshot } from '../src/core/crawl.ts';
/**
* Exercises the store against a real Postgres — the generation/diff semantics
* are entirely SQL, so a mock would test nothing. Skipped when TEST_DATABASE_URL
* is unset so `npm test` stays offline by default.
*/
const DB_URL = process.env.TEST_DATABASE_URL;
/**
* These tests TRUNCATE. Pointing them at a real database destroys it — which
* happened once during development, when TEST_DATABASE_URL was aimed at the dev
* instance and the fixtures ended up in live data. Requiring "test" in the
* database name makes that mistake impossible to repeat by accident.
*/
function assertDisposable(url: string): void {
const name = new globalThis.URL(url).pathname.replace(/^\//, '');
if (!/test/i.test(name)) {
throw new Error(
`Refusing to run: TEST_DATABASE_URL points at database "${name}", which is not obviously ` +
`disposable. These tests TRUNCATE. Use a database with "test" in its name.`,
);
}
}
function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot {
return {
crawledAt: new Date(),
schoolId: 'school1',
failures: [],
courses: courses.map((c) => ({
course: { id: c.id, title: c.title, shortTitle: c.title.slice(0, 2), displayColor: '#000' },
title: c.title,
boards: c.boardText
? [{ id: `${c.id}-b`, title: 'Board', courseId: c.id, text: c.boardText,
board: { id: `${c.id}-b`, title: 'Board', columns: [], fileCount: 0 } }]
: [],
lessons: [],
tasks: [],
})),
files: courses.flatMap((c) =>
(c.files ?? []).map((f) => ({
record: { id: f.id, name: f.name, parentId: 'p', parentType: 'boardnodes' as const,
url: '', size: f.size, mimeType: 'application/pdf',
securityCheckStatus: 'verified', previewStatus: 'x' },
parentType: 'boardnodes' as const,
parentId: 'p',
at: { courseId: c.id, courseTitle: c.title, containerTitle: 'Board' },
})),
),
};
}
describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, () => {
let store: Store;
before(async () => {
assertDisposable(DB_URL!);
const opened = await Store.open(DB_URL);
assert.ok(opened, 'store should open');
store = opened;
// Start from a clean slate so generation ids are predictable.
await (store as never as { db: { query: (q: string) => Promise<unknown> } }).db.query(
'TRUNCATE crawls, file_texts RESTART IDENTITY CASCADE',
);
});
after(async () => {
await store?.close();
});
it('returns undefined rather than throwing when the database is unreachable', async () => {
const dead = await Store.open('postgresql://nobody@127.0.0.1:1/none');
assert.equal(dead, undefined);
});
it('saves a generation and reports stats', async () => {
const id = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Bruchrechnung', files: [{ id: 'f1', name: 'a.pdf', size: 10 }] }]), 'full');
assert.ok(id > 0);
const stats = await store.stats();
assert.equal(stats.crawlId, id);
assert.equal(stats.files, 1);
});
it('diffs by identity, reporting additions and deletions', async () => {
const first = await store.latestCrawlId();
const second = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Bruchrechnung', files: [{ id: 'f2', name: 'b.pdf', size: 20 }] }]),
'full',
);
const diff = await store.diff(first!, second);
assert.ok(diff.added.some((n) => n.nodeId === 'f2'), 'f2 added');
assert.ok(diff.removed.some((n) => n.nodeId === 'f1'), 'f1 removed — timestamps could never show this');
});
it('reports a content change even when the id is unchanged', async () => {
const before = await store.latestCrawlId();
const after = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung', files: [{ id: 'f2', name: 'b.pdf', size: 20 }] }]),
'full',
);
const diff = await store.diff(before!, after);
assert.ok(diff.changed.some((n) => n.nodeId === 'c1-b'), 'board body change detected via digest');
});
it('reports a renamed file, which keeps its id and size', async () => {
// `PATCH /file/rename/{id}` renames a record in place. The digest once
// covered only id and size on the assumption that file records never
// change, so a rename went unreported — the file just quietly appeared
// under a new name.
const before = await store.latestCrawlId();
const after = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung', files: [{ id: 'f2', name: 'b-v2.pdf', size: 20 }] }]),
'full',
);
const diff = await store.diff(before!, after);
assert.ok(diff.changed.some((n) => n.nodeId === 'f2'), 'rename detected');
assert.ok(!diff.added.some((n) => n.nodeId === 'f2'), 'a rename is not a new file');
assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one');
});
it('carries other courses forward on a per-course crawl', async () => {
await store.saveSnapshot(
snapshot([
{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' },
{ id: 'c2', title: 'Physik', boardText: 'Optik' },
]),
'full',
);
const before = await store.latestCrawlId();
// Re-crawl only c2; c1 must survive rather than looking deleted.
const after = await store.saveSnapshot(snapshot([{ id: 'c2', title: 'Physik', boardText: 'Mechanik' }]), 'c2');
const diff = await store.diff(before!, after);
assert.equal(diff.removed.length, 0, 'a partial crawl must not look like a mass deletion');
assert.ok(diff.changed.some((n) => n.nodeId === 'c2-b'));
});
it('finds German content with stemming', async () => {
const hits = await store.search('Mechanik');
assert.ok(hits.length > 0, 'expected a hit for Mechanik');
});
it('makes extracted file text searchable', async () => {
await store.saveSnapshot(snapshot([{ id: 'c3', title: 'Info', files: [{ id: 'f9', name: 'skript.pdf', size: 99 }] }]), 'full');
await store.recordFileText({
fileId: 'f9', name: 'skript.pdf', mimeType: 'application/pdf', size: 99,
content: 'Die Cäsar-Verschlüsselung verschiebt Buchstaben im Alphabet.',
note: 'ok', mirrorPath: 'Info/Board/skript.pdf', mirrorSize: 99,
});
const hits = await store.search('Verschlüsselung');
assert.ok(hits.some((h) => h.nodeId === 'f9'), 'PDF contents should be searchable, not just the filename');
});
it('resolves a timestamp cursor to a generation', async () => {
const id = await store.resolveCursor(new Date().toISOString());
assert.ok(id && id > 0);
assert.equal(await store.resolveCursor('not-a-date'), undefined);
});
it('builds a manifest with per-file change status', async () => {
const { entries } = await store.manifest();
assert.ok(entries.some((e) => e.fileId === 'f9' && e.path.startsWith('Info/')));
});
})