Files
Schulcloud-MCP/test/extract.test.ts
MechaCat02 c79f1b120d Index-backed search, /api surfaces, image-only PDF detection
search now queries the Postgres index and states its freshness in every
result, with fresh=true bypassing it for a live crawl — the agent can
always get current data rather than being quietly misled by a stale
index. Adds refresh_index (per-course by default; a full crawl is ~270
requests), what_changed (generation diff — the API has no changed-since
filter of any kind), and index_status.

/api gives the CLI its backend behind the same bearer token as /mcp:
GET /manifest (cursor + per-file status), GET /files/:id (served from
the mirror with Range support, falling back to a live proxy for files
too large to mirror), GET /status, POST /refresh. Bytes go over plain
HTTP rather than MCP because base64 in JSON-RPC costs a third more and
buffers whole files. An unresolvable manifest cursor returns 409 rather
than silently meaning "everything is new", so a client cannot be tricked
into a full re-download.

Verified end to end against the live instance and a real Postgres:
crawl -> index -> German FTS -> manifest -> ranged download, with 401
on missing token, 400 on a malformed id, and 429 on a too-soon refresh.

Two findings worth recording. The build silently omitted the .sql
migrations from dist, which the store's graceful degradation turned into
"running without the index" rather than a crash — now copied by a build
step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they
are scans, so extraction legitimately yields nothing. That is now
detected and reported as image-only with OCR named as the missing piece,
instead of an indistinguishable "0 characters". It revises the roadmap's
"OCR not needed" note, which held for reading images but not for
indexing them.

49 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:16:38 +02:00

104 lines
4.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { extractContent, formatBytes } from '../src/core/extract.ts';
const MAX = 10_000;
describe('extractContent', () => {
it('returns images inline as base64 without touching the bytes', async () => {
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const result = await extractContent(png, 'image/png', 'a.png', MAX);
assert.equal(result.kind, 'image');
assert.equal(result.image?.base64, png.toString('base64'));
assert.equal(result.image?.mimeType, 'image/png');
});
it('reads plain text and normalises CRLF', async () => {
const result = await extractContent(Buffer.from('a\r\nb\r\n\r\n\r\n\r\nc'), 'text/plain', 'a.txt', MAX);
assert.equal(result.kind, 'text');
assert.equal(result.text, 'a\nb\n\nc');
});
it('recognises text even when the server mislabels it as octet-stream', async () => {
const result = await extractContent(Buffer.from('hello world'), 'application/octet-stream', 'note', MAX);
assert.equal(result.kind, 'text');
assert.equal(result.text, 'hello world');
});
it('reports binary content instead of emitting mojibake', async () => {
const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]);
const result = await extractContent(bytes, 'application/octet-stream', 'blob.bin', MAX);
assert.equal(result.kind, 'binary');
assert.match(result.note, /no text extractor/);
});
it('truncates at the limit and says so', async () => {
const result = await extractContent(Buffer.from('x'.repeat(5000)), 'text/plain', 'a.txt', 100);
assert.equal(result.truncated, true);
assert.equal(result.text?.length, 100);
assert.match(result.note, /truncated to 100 characters \(of 5000\)/);
});
it('turns a parser failure into a note rather than throwing', async () => {
const result = await extractContent(Buffer.from('not really a pdf'), 'application/pdf', 'broken.pdf', MAX);
assert.equal(result.kind, 'binary');
assert.match(result.note, /Could not extract text|no text extractor/);
});
});
describe('formatBytes', () => {
it('scales units', () => {
assert.equal(formatBytes(512), '512 B');
assert.equal(formatBytes(2048), '2.0 KB');
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB');
});
});
/**
* Builds a structurally valid PDF with correct xref offsets — pdfjs rejects
* anything less, so a hand-waved byte string would test the error path instead
* of the one we care about.
*/
function minimalPdf(options: { withFont: boolean }): Buffer {
const content = options.withFont
? 'BT /F1 12 Tf 10 100 Td (Hallo Welt) Tj ET'
: 'q 100 0 0 100 10 10 cm /Im0 Do Q'; // draws an image, no text operators
const objs = [
'<</Type/Catalog/Pages 2 0 R>>',
'<</Type/Pages/Kids[3 0 R]/Count 1>>',
`<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Resources<<${
options.withFont ? '/Font<</F1 5 0 R>>' : '/XObject<</Im0 5 0 R>>'
}>>/Contents 4 0 R>>`,
`<</Length ${content.length}>>stream\n${content}\nendstream`,
options.withFont ? '<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>' : '<</Subtype/Image/Width 1/Height 1>>',
];
let out = '%PDF-1.4\n';
const offsets: number[] = [];
objs.forEach((body, index) => {
offsets.push(out.length);
out += `${index + 1} 0 obj${body}endobj\n`;
});
const xref = out.length;
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) out += `${String(offset).padStart(10, '0')} 00000 n \n`;
out += `trailer<</Size ${objs.length + 1}/Root 1 0 R>>\nstartxref\n${xref}\n%%EOF`;
return Buffer.from(out, 'latin1');
}
describe('image-only PDFs', () => {
it('extracts normally when the PDF has a text layer', async () => {
const result = await extractContent(minimalPdf({ withFont: true }), 'application/pdf', 'doc.pdf', 10_000);
assert.equal(result.kind, 'text');
assert.match(result.text ?? '', /Hallo Welt/);
});
it('reports a missing text layer rather than a bare zero-character result', async () => {
// Measured on the real account: 3 of 4 sampled course PDFs are image-only,
// so "0 characters" must not look like a parser failure.
const result = await extractContent(minimalPdf({ withFont: false }), 'application/pdf', 'scan.pdf', 10_000);
assert.equal(result.kind, 'binary');
assert.match(result.note, /image-only PDF/);
assert.match(result.note, /OCR/);
});
});