Files
Schulcloud-MCP/test/render.test.ts
MechaCat02 a8badc2fd1 Read submitted text and teacher feedback from the homework page
You were right that this data never reaches the browser as an API call.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not
exposed publicly — /api/v1/* 404s — so the rendered page is the only way
to reach these fields from outside.

core/homework-page.ts parses it, hooked on the data-testid attributes
the project's own e2e tests use rather than incidental markup. The page
authenticates by jwt *cookie*; an Authorization header is ignored and
redirects to the identity provider. Every field is optional and parse
failures return undefined, so a markup change degrades to "not found"
and cannot break get_task. The wording distinguishes the two: absent
feedback is reported as not found, never as none given.

Measured on one course: 4 of 7 graded submissions carry feedback no API
call can return — "vollständig und nachvollziehbar", "Feedback siehe
Zettel", and so on.

This exposed a bug in a shared utility: htmlToText decoded only six
entities, so any named entity passed through raw. German content makes
that routine — "vollständig" would have reached the model verbatim
from boards and task descriptions too, not just here. It now decodes
named, decimal and hex references in one pass, so ä stays
literal instead of decoding twice, and leaves unknown names alone rather
than mangling them.

Also fixes a documented-recovery bug found while restoring the session:
`docker compose restart` does not re-read env_file, so it silently kept
serving the dead token. `up -d` is correct and the docs said the wrong
thing.

78 tests, 38/38 smoke; verified end to end through Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:25:35 +02:00

97 lines
3.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { daysUntil, formatDate, htmlToText, joinSections, normalizeObjectId } from '../src/core/text.ts';
describe('htmlToText', () => {
it('unwraps the CKEditor markup Schulcloud stores', () => {
assert.equal(htmlToText('<p>Hallo <strong>Welt</strong></p>'), 'Hallo Welt');
});
it('keeps the href when the link text differs from it', () => {
assert.equal(
htmlToText('<p><a href="https://example.org/x">Beispiel</a></p>'),
'Beispiel (https://example.org/x)',
);
});
it('does not duplicate a bare URL used as its own label', () => {
assert.equal(htmlToText('<a href="https://example.org">https://example.org</a>'), 'https://example.org');
});
it('renders list items as bullets and collapses blank runs', () => {
assert.equal(htmlToText('<ul><li>eins</li><li>zwei</li></ul>'), '- eins\n- zwei');
});
it('decodes entities, ampersand last so &amp;lt; stays literal', () => {
assert.equal(htmlToText('<p>a &amp;lt; b &lt; c &nbsp;d</p>'), 'a &lt; b < c d');
});
it('returns an empty string for missing input', () => {
assert.equal(htmlToText(undefined), '');
assert.equal(htmlToText(null), '');
});
});
describe('formatDate', () => {
it('renders ISO timestamps as minute-precision UTC', () => {
assert.equal(formatDate('2026-08-17T08:00:00.000Z'), '2026-08-17 08:00');
});
it('passes through unparseable values rather than printing Invalid Date', () => {
assert.equal(formatDate('not a date'), 'not a date');
});
it('marks absent dates', () => {
assert.equal(formatDate(null), '—');
});
});
describe('daysUntil', () => {
it('is negative for past dates and undefined when unset', () => {
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
assert.ok((daysUntil(yesterday) ?? 0) < 0);
assert.equal(daysUntil(undefined), undefined);
});
});
describe('joinSections', () => {
it('drops empty and falsy parts', () => {
assert.equal(joinSections(['a', '', undefined, false, ' ', 'b']), 'a\n\nb');
});
});
describe('normalizeObjectId', () => {
it('converts the buffer shape the legacy lesson API returns', () => {
const id = { buffer: { type: 'Buffer', data: [106, 130, 219, 101, 127, 25, 207, 115, 254, 60, 242, 13] } };
assert.equal(normalizeObjectId(id), '6a82db657f19cf73fe3cf20d');
});
it('passes plain strings through and gives up on anything else', () => {
assert.equal(normalizeObjectId('abc'), 'abc');
assert.equal(normalizeObjectId({}), undefined);
});
});
describe('decodeEntities', () => {
it('decodes German umlauts, which the legacy pages emit as named entities', () => {
assert.equal(htmlToText('<p>vollst&auml;ndig und nachvollziehbar</p>'), 'vollständig und nachvollziehbar');
assert.equal(htmlToText('<p>Gr&ouml;&szlig;e, &Uuml;bung</p>'), 'Größe, Übung');
});
it('decodes decimal and hex numeric references', () => {
assert.equal(htmlToText('<p>&#8364; &#x20AC; &#228;</p>'), '€ € ä');
});
it('does not double-decode: &amp;auml; stays literal text', () => {
assert.equal(htmlToText('<p>&amp;auml;</p>'), '&auml;');
});
it('leaves unknown entities alone rather than mangling them', () => {
assert.equal(htmlToText('<p>&notarealentity; &amp;</p>'), '&notarealentity; &');
});
it('ignores out-of-range numeric references', () => {
assert.equal(htmlToText('<p>&#1114112;</p>'), '&#1114112;');
});
});