Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
12 KiB
TypeScript
274 lines
12 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 }[] }[],
|
|
rooms: { id: string; name: string; boardText?: string }[] = [],
|
|
notes: { path: string; title: string; text: string; subject?: string; date?: string }[] = [],
|
|
): Snapshot {
|
|
return {
|
|
crawledAt: new Date(),
|
|
schoolId: 'school1',
|
|
failures: [],
|
|
submissions: [],
|
|
lessonLog: [],
|
|
notes: notes.map((n) => ({
|
|
path: n.path,
|
|
title: n.title,
|
|
text: n.text,
|
|
...(n.subject ? { subject: n.subject } : {}),
|
|
...(n.date ? { date: n.date } : {}),
|
|
tags: [],
|
|
modifiedAt: '2026-09-15T10:00:00.000Z',
|
|
bytes: n.text.length,
|
|
})),
|
|
rooms: rooms.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
boards: r.boardText
|
|
? [{ id: `${r.id}-b`, title: 'Raum-Board', courseId: r.id, text: r.boardText,
|
|
board: { id: `${r.id}-b`, title: 'Raum-Board', columns: [], fileCount: 0 } }]
|
|
: [],
|
|
})),
|
|
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('stores rooms alongside courses, and notices when one changes', async () => {
|
|
// Rooms are a separate space, not a kind of course: they must land in the
|
|
// index under their own kind, or room content becomes unsearchable the way
|
|
// submitted files once were.
|
|
const before = await store.saveSnapshot(
|
|
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum', boardText: 'Projektsteuerung' }]),
|
|
'full',
|
|
);
|
|
const hits = await store.search('Projektsteuerung', { limit: 5 });
|
|
assert.ok(hits.some((h) => h.nodeId === 'r1-b'), 'a room board is searchable');
|
|
|
|
const after = await store.saveSnapshot(
|
|
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum Informatik', boardText: 'Projektsteuerung' }]),
|
|
'full',
|
|
);
|
|
const diff = await store.diff(before, after);
|
|
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
|
|
});
|
|
|
|
it('indexes the user\'s own notes beside the course material', async () => {
|
|
// The point of the notes store: one search covers what the school
|
|
// uploaded and what the user wrote down in the lesson.
|
|
const before = await store.saveSnapshot(
|
|
snapshot(
|
|
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
|
[],
|
|
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Frau Meier betont den Schluss.' }],
|
|
),
|
|
'full',
|
|
);
|
|
const hits = await store.search('Erörterung', { limit: 5 });
|
|
const note = hits.find((hit) => hit.kind === 'note');
|
|
assert.ok(note, 'a note is searchable');
|
|
assert.equal(note.nodeId, 'Deutsch/2026-09-15 Erörterung.md', 'the path is the id get_note takes');
|
|
assert.equal(note.meta?.subject, 'Deutsch');
|
|
|
|
// And an edited note is a change, so what_changed reports it.
|
|
const after = await store.saveSnapshot(
|
|
snapshot(
|
|
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
|
[],
|
|
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Gegenargument nicht vergessen.' }],
|
|
),
|
|
'full',
|
|
);
|
|
const diff = await store.diff(before, after);
|
|
assert.ok(diff.changed.some((n) => n.kind === 'note'), 'an edited note is reported as changed');
|
|
});
|
|
|
|
it('keeps notes through a per-course crawl, which never looks at them', async () => {
|
|
// Notes belong to the account, not to a course, so a per-course refresh
|
|
// must carry them forward rather than appear to delete them.
|
|
const before = await store.latestCrawlId();
|
|
const after = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }]), 'c1');
|
|
const diff = await store.diff(before!, after);
|
|
assert.ok(!diff.removed.some((n) => n.kind === 'note'), 'a per-course crawl must not delete the notes');
|
|
});
|
|
|
|
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('stores extracted text containing NUL, which Postgres text refuses', async () => {
|
|
await store.recordFileText({
|
|
fileId: 'f9', name: 'skript.pdf', mimeType: 'application/pdf', size: 99,
|
|
content: 'Webserver\u0000 und Proxy', note: 'ok\u0000', mirrorPath: 'Info/Board/skript.pdf', mirrorSize: 99,
|
|
});
|
|
const hits = await store.search('Proxy');
|
|
assert.ok(hits.some((h) => h.nodeId === 'f9'), 'text with NUL bytes should still be stored and searchable');
|
|
});
|
|
|
|
it('keeps a file whose download failed queued for the next crawl, but not one that failed to extract', async () => {
|
|
await store.recordFileText({
|
|
fileId: 'f9', name: 'skript.pdf', mimeType: 'application/pdf', size: 99,
|
|
content: null, note: 'download failed, retried on the next crawl: timeout',
|
|
mirrorPath: null, mirrorSize: null, retry: true,
|
|
});
|
|
assert.ok((await store.filesNeedingText()).some((f) => f.fileId === 'f9'), 'a transient failure must be retried');
|
|
|
|
await store.recordFileText({
|
|
fileId: 'f9', name: 'skript.pdf', mimeType: 'application/pdf', size: 99,
|
|
content: null, note: 'extraction failed: bad xref', mirrorPath: null, mirrorSize: null,
|
|
});
|
|
assert.ok(!(await store.filesNeedingText()).some((f) => f.fileId === 'f9'), 'a parser failure is final');
|
|
});
|
|
|
|
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/')));
|
|
});
|
|
})
|