pediatric-ai-scribe-v3/test/my-resources.test.js
Daniel 025290d64a
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: retire Learning Hub
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00

385 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
test('anyone signed in can generate here — no moderator gate', () => {
const route = read('src/routes/myResources.js');
// This existed alongside Learning Hub, which was moderator-owned, so that not
// being a moderator did not mean not being able to generate anything at all.
// Learning Hub is retired and this is the only generator left; the absence of
// a role gate still matters, and is still worth pinning.
assert.doesNotMatch(route, /moderatorMiddleware/);
assert.match(route, /router\.use\('\/my-resources', authMiddleware\)/,
'signed in is the only requirement, and the gate names its own prefix');
});
test('nothing here can return another persons work', () => {
const route = read('src/routes/myResources.js');
// Every statement that touches the table filters on the owner. A missing
// WHERE clause here is the whole risk, so it is asserted rather than assumed.
const statements = route.match(/'(SELECT|UPDATE|DELETE|INSERT)[^']*(?:' \+\s*\n\s*'[^']*)*'/g) || [];
const touching = statements.filter(s => /user_resources/.test(s));
assert.ok(touching.length >= 5, 'expected the table statements to be found');
for (const s of touching) {
if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead
assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60));
}
assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one');
});
test('markdown is the artifact; every format is rendered from it', () => {
const exporter = read('src/utils/documentExport.js');
// Refining means editing text, never patching a binary — which is what makes
// "change slide 4" possible at all.
assert.match(exporter, /async function render\(markdown, kind, format, options\)/);
assert.match(exporter, /var office = kind === 'presentation' \? 'pptx' : 'docx';/);
assert.match(exporter, /--reference-doc=' \+ REFERENCE_DECK/, 'decks keep the house template');
// PDF goes through Gotenberg because pandoc ships no PDF engine in this
// image, and converting the office file preserves the deck's layout.
assert.match(exporter, /forms\/libreoffice\/convert/);
assert.match(exporter, /AbortSignal\.timeout\(90000\)/, 'and cannot hang a request');
// Temporary directories are always cleaned, including on failure.
assert.match(exporter, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
});
test('a failed PDF says so, because the other two formats still work', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /PDF conversion is unavailable right now\. PowerPoint and Word still work\./);
// Gotenberg is a different stack; PDF is the one export allowed to fail.
assert.match(read('src/utils/documentExport.js'), /GOTENBERG_URL \|\| 'http:\/\/gotenberg:3000'/);
assert.match(read('docker-compose.yml'), /danvics_convert/, 'and ped-ai is on its network');
});
test('the generated markdown is told the rules pandoc enforces', () => {
const route = read('src/routes/myResources.js');
// The same rules the Learning prompt carries, found by rendering decks and
// looking at them: a table alone on its slide, blank lines around it, no
// "Slide 3:" prefixes, no deep nesting.
assert.match(route, /A slide containing a table contains ONLY that table/);
assert.match(route, /a table needs a blank line/);
assert.match(route, /the heading is the slide\\'s subject, not "Slide 3:"/);
// And grounded resources cite only at the end.
assert.match(route, /Do NOT cite in the body/);
assert.match(route, /In a presentation that is the final slide, titled References/);
});
test('a library has a ceiling, and generation says when it is reached', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /var MAX_PER_USER = 100;/);
assert.match(route, /You have reached ' \+ MAX_PER_USER \+ ' saved resources/);
// The count is per owner, so one person filling their library cannot stop
// anyone else generating.
assert.match(route, /SELECT COUNT\(\*\)::int AS n FROM user_resources WHERE user_id = \?/);
});
test('the screen is reachable by anyone signed in, and states that it is private', () => {
const index = read('public/index.html');
const component = read('public/components/my-resources.html');
// A menu item of its own, next to the Learning Hub: related, not the same
// thing, and sitting together is how someone discovers the difference.
assert.match(index, /<button class="tab-btn" data-tab="myresources">/);
assert.match(index, /<section id="myresources-tab" class="tab-content" data-component="my-resources">/);
// No role gate in the markup: the tab button carries no hidden class, unlike
// the admin and CMS ones which JavaScript reveals per role.
const button = index.slice(index.indexOf('data-tab="myresources"') - 40, index.indexOf('data-tab="myresources"') + 40);
assert.doesNotMatch(button, /hidden/, 'visible to every signed-in user');
// Said in the header. It used to be repeated in a paragraph below; the claim
// is what matters, not that it was made twice.
assert.match(component, /Only you can see these/);
});
test('a row offers the right formats, and the download carries its auth', () => {
const js = read('public/js/myResources.js');
assert.match(js, /formats\.forEach\(function \(format\)/);
// An <a href> cannot carry the Authorization header, so the file is fetched
// and saved from a blob instead of linked.
assert.match(js, /headers: getAuthHeaders\(\)/);
assert.match(js, /filename="\(\[\^"\]\+\)"/, 'and keeps the name the server chose');
assert.match(js, /URL\.revokeObjectURL\(url\)/, 'without leaking the object URL');
// Titles come from a model; this is where they reach the page.
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
});
test('users pick from the models an admin already approved, and nothing else', () => {
const route = read('src/routes/myResources.js');
// One allow-list, the one chat already uses. A second would be another thing
// to keep in step, and would let this reach a model nobody approved.
assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
// A stale option in an open browser tab must not cost someone their
// generation, so an unknown model falls back rather than being refused.
assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
// Refining goes through the same resolution, not req.body.model directly.
assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
// And the screen only asks when there is a real choice to make.
const js = read('public/js/myResources.js');
assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
});
test('illustration is opt-in, with its own dispatcher rather than the assistants', () => {
const route = read('src/routes/myResources.js');
// A model handed a drawing tool will find a reason to use it, so the tool is
// only offered when the author asked for one.
assert.match(route, /var wantsImages = String\(body\.withImages\) === 'true'/);
// Opt-in is the checkbox's own default, which is the fact worth pinning —
// stronger than the sentence that used to explain it.
assert.match(read('public/components/my-resources.html'),
/<input type="checkbox" id="mr-with-images">/, 'unchecked by default');
// Tools are assembled per generation: only what the author asked for.
// Illustration is the only thing left that is genuinely a tool: it needs the
// model to decide there should be a picture and to compose the prompt for it.
// Search does not — see web-search.test.js for why both searches were taken
// away from the model and run by the route instead.
// In deck mode the slides name their own figures, so there is nothing for a
// tool to decide; an article still gets the tool, having no structure to hang
// a figure on.
assert.match(route, /if \(wantsImages && !deckMode\) tools = tools\.concat\(resourceImages\.tools\);/);
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
// Its own dispatcher. The assistant's permits one image per request, which is
// right for a chat reply and wrong for a deck, and three features depend on
// that rule — so this is a separate path rather than a relaxed shared one.
assert.match(route, /resourceImages\.dispatch\(ai, \{/);
assert.doesNotMatch(route, /imageTool/, 'the shared single-image dispatcher is not used here');
assert.match(read('src/utils/imageTool.js'), /Only one image tool invocation is permitted per request/,
'and its limit is left exactly as it was');
assert.match(read('src/utils/resourceImages.js'), /'my_resources'/, 'but attributed to this feature');
// The dispatch call itself. It was lost once in a refactor: the tool was
// still offered, the model still called it, and the call was silently
// dropped, so no job was ever enqueued and imageJobs was always empty.
assert.match(route, /ai = await resourceImages\.dispatch\(ai, \{/);
// dispatch expects { request, history }; a bare topic string made the bound
// request undefined and lost the topic entirely.
assert.match(read('src/utils/resourceImages.js'), /images\.imageContext\(opts\.subject, \[\]\)/);
// A model handed a tool schema and then told to "Output ONLY Pandoc markdown"
// obeys the sentence, not the schema — measured: zero tool calls until the
// prompt said the tool existed and that calling it was not a violation.
assert.match(route, /is about the written resource; a tool call is not a violation of it/);
assert.match(route, /wantsImages: Boolean\(wantsImages && imageModel\)/);
// Its own workflow, and the only two left now Learning Hub is retired. The
// barrier this used to describe — keeping a private illustration out of
// published content — is now absolute: there is no published content, and an
// image is readable by its owner alone.
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'my_resources'\];/);
assert.match(read('migrations/1780800000000_retire-learning-hub.js'), /'clinical_assistant'::text, 'my_resources'::text/);
// Status polling is owner-scoped and workflow-scoped, so it can only report
// on an image the caller made here.
assert.match(route, /service\(\)\.get\(req\.params\.id, req\.user\.id, 'my_resources'\)/);
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
// And it renders where the person is looking, rather than pointing them at an
// image history this feature does not have.
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
// The row is hidden entirely when no image model is configured.
assert.match(read('public/js/myResources.js'), /\['mr-images-row', 'images'\]/);
});
test('the author can ask for the illustration, not only leave it to the model', () => {
const route = read('src/routes/myResources.js');
// Without this the decision is the model's alone, and someone who wants a
// figure of something particular has no way to say so — the instructions
// steer the prose and nothing else.
assert.match(route, /If the author\\'s additional instructions above name what a figure should show/);
assert.match(route, /compose the image description from/);
// How many, when the author says. "use 3 images" is as clear an instruction
// as any other and used to be capped at one figure regardless.
const lib = read('src/utils/resourceImages.js');
assert.match(lib, /function requestedCount\(text\)/);
assert.match(lib, /var MAX_IMAGES = 6;/, 'bounded, because each figure is a paid request');
assert.match(lib, /jobs\.length < MAX_IMAGES/);
// A key per figure, or the second is returned as a replay of the first — and
// keyed on the figure rather than on its index, or two generations from the
// same form collide with each other. See image-key-collision.test.js.
assert.match(lib, /'res:' \+ images\.requestKey\(opts\.body\) \+ ':' \+ drawing/);
assert.match(lib, /createHash\('sha256'\)\s*\n?\s*\.update\(JSON\.stringify\(input\)\)/);
assert.match(route, /resourceImages\.guidance\(opts\.refinement\)/, 'generate');
assert.match(route, /resourceImages\.guidance\(instructions\)/, 'and modify');
// A figure that cannot be queued is said out loud; fewer pictures than asked
// for with no explanation reads as the model ignoring the request.
assert.match(lib, /failures\.push/);
assert.match(read('public/js/myResources.js'), /function reportImageFailures/);
// The paragraph now comes last, so it says "above" — checked, because a
// prompt that points the model at the wrong end of itself is worse than one
// that says nothing.
assert.match(route, /additional instructions above name what a figure should show/);
assert.doesNotMatch(route, /instructions below/);
// Said once on screen too: the label points at Instructions, and the
// Instructions placeholder shows what asking for one looks like.
const html = read('public/components/my-resources.html');
assert.match(html, /use 3 diagrams/, 'and the placeholder shows that asking for several works');
assert.match(html, /Add illustrations &mdash; say how many in Instructions/);
// Saying it in the instructions is as clear as ticking the box, so the box
// follows rather than the request being dropped in silence.
const js = read('public/js/myResources.js');
assert.match(js, /function looksLikeImageRequest/);
assert.match(js, /Illustration switched on, because your instructions ask for a figure/);
assert.match(js, /no image model is configured, so none can be made/);
assert.match(js, /if \(!check\.checked\) overruled = true;/, 'and switching it off by hand sticks');
assert.match(js, /wireImageIntent\('mr-refinement', 'mr-with-images', 'mr-image-hint'\)/);
assert.match(js, /wireImageIntent\('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'\)/);
});
test('a named number of figures survives a prompt full of library excerpts', () => {
const route = read('src/routes/myResources.js');
const lib = read('src/utils/resourceImages.js');
// Measured, and deterministic on this model: with the library off, "use 3
// diagrams" produced three tool calls; with thirty excerpts in the prompt it
// produced none and a longer deck instead. The excerpts are not wrong to
// dominate — the request simply has to survive them.
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(refinement\)\) callOptions\.toolChoice = 'required';/);
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(instructions\)\) callOptions\.toolChoice = 'required';/);
// With no number named the choice stays the model's.
assert.doesNotMatch(route, /toolChoice = 'required';\s*\n\s*var ai = await callAI\(messages, Object/);
// Placement matters as much as wording: the illustration paragraph goes after
// the output rules and the author's instructions, because read before them it
// lost to a long "Output ONLY Pandoc markdown" block.
const tail = route.slice(route.indexOf("return 'You are writing teaching material"));
assert.ok(tail.indexOf('+ illustration;') > tail.indexOf('Additional instructions'),
'illustration guidance is the last thing in the prompt');
// A model that has just made three tool calls tends to sign off rather than
// write. Measured: "I'll create the presentation and the three teaching
// diagrams." — 61 characters, saved as the resource, because only a
// completely empty body counted as missing.
assert.match(lib, /function looksLikeResource\(content\)/);
assert.match(lib, /if \(!looksLikeResource\(ai && ai\.content\)\)/);
assert.match(lib, /\^%\/m\.test\(text\) \|\| \/\^#\{1,2\}/, 'a title block or a heading, not mere length');
// And if the continuation is no better, keep whichever actually reads like one.
assert.match(lib, /completed = ai;/);
});
test('the library is bounded, searchable, and drives the modify picker', () => {
const html = read('public/components/my-resources.html');
// Unbounded, a long library pushes everything else off the page.
assert.match(html, /id="mr-list"[^>]*max-height:360px;overflow-y:auto;/);
assert.match(html, /id="mr-search"/);
const js = read('public/js/myResources.js');
// Filtering is local — the rows are already in hand, so it costs no request.
assert.match(js, /search\.addEventListener\('input', renderLibrary\)/);
assert.match(js, /var rows = library\.filter/);
assert.match(js, /String\(row\.title \|\| ''\) \+ ' ' \+ String\(row\.topic \|\| ''\)/, 'title and topic both searched');
// "Nothing yet" and "nothing matches" are different situations.
assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/);
});
test('a figure asked for while modifying belongs to a slide', () => {
const route = read('src/routes/myResources.js');
// How a figure is asked for depends on what is being edited. A deck places
// figures by declaring them on a slide; markdown has nowhere to put one, so
// it uses the tool. Offering the tool while editing a deck queued a figure
// that no slide referenced — generated, paid for, recorded against the
// resource, and absent from the export. Measured: 1 recorded, 0 on a slide.
assert.match(route, /var tools = sources\.wantsImages && !existingDeck \? resourceImages\.tools : \[\];/);
assert.match(route, /if \(sources\.wantsImages && !existingDeck\) \{\s*\n\s*ai = await resourceImages\.dispatch/);
// The deck path draws what the revised deck asked for, as generating does.
assert.match(route, /if \(sources\.wantsImages\) \{\s*\n\s*var drawn = await deckBuild\.drawFigures\(revisedDeck/);
// And a slide that already has a figure keeps it.
assert.match(route, /keep the "image_job" value of any slide that already has one/);
});
test('the figure ids are computed before anything reads them', () => {
const route = read('src/routes/myResources.js');
// They were declared inside the slide-review branch, so with no reviewer
// configured — the default — the value was undefined by the time the INSERT
// stringified it, and every generation failed on a NOT NULL constraint. `var`
// is function-scoped, so nothing complained until the database did. This is
// the second bug of exactly this shape in this file.
const body = route.slice(route.indexOf("router.post('/my-resources/generate'"));
const declared = body.indexOf('var savedFigureIds =');
const reviewUses = body.indexOf('renderDeck(deck, [], savedFigureIds)');
const insertUses = body.indexOf('JSON.stringify(savedFigureIds)');
assert.ok(declared > -1 && declared < reviewUses, 'declared before the review reads it');
assert.ok(declared < insertUses, 'and before the insert reads it');
// Declared at the top level of the handler, not inside a branch.
assert.doesNotMatch(body.slice(0, insertUses), /if \(reviewModel\) \{\s*\n\s*var savedFigureIds/);
});
test('modifying a presentation edits the deck, not only its markdown', () => {
const route = read('src/routes/myResources.js');
// Export renders from the stored deck. Refine edited the markdown beside it
// and left the deck alone, so a modification reported success, updated the
// library row, and produced a byte-identical download. Measured: markdown
// gained the new slide, the deck did not, and the exported pptx did not.
assert.match(route, /SELECT id, kind, topic, markdown, deck, image_ids FROM user_resources/);
assert.match(route, /var revisedDeck = existingDeck \? deckBuild\.parse\(ai && ai\.content\) : null;/);
assert.match(route, /deck = COALESCE\(\?::jsonb, deck\)/, 'and the deck is written back');
// A reply that is not a deck must not be saved as markdown: that would drop
// every layout the deck held while looking like it worked.
assert.match(route, /outcome: 'refused',\s*\n\s*detail: 'the model did not return a usable deck'/);
assert.match(route, /That change could not be applied\. Try wording it differently\./);
// An article has no deck and keeps the markdown path.
assert.match(route, /: 'Revise the following Pandoc markdown according to the instruction\. '/);
// The reply restates the whole resource, so it needs room for one.
assert.match(route, /temperature: 0\.2, maxTokens: 16000/);
});
test('modify revises something already generated, in place', () => {
const js = read('public/js/myResources.js');
// The endpoint existed with no way to reach it: the markdown is what is
// stored precisely so that "redo slide 4" is a text edit.
assert.match(js, /\/refine'/);
assert.match(js, /instructions: instructions/);
// The picker is the library, so it cannot drift from it, and a selection
// survives the refresh that follows a generation.
assert.match(js, /function syncModifyTargets\(\)/);
assert.match(js, /var previous = select\.value;/);
assert.match(js, /if \(previous && library\.some/);
// Refusals are local rather than a wasted round trip.
assert.match(js, /if \(!instructions\) return say\('Say what to change\.', 'bad'\);/);
// And the screen says the old version is gone, because it is.
assert.match(read('public/components/my-resources.html'), /The previous version is replaced/);
});
test('a slide shrinks its text rather than spilling off the bottom', () => {
// pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with
// no autofit even though the slide master has one. Rendered and counted: a
// slide with eight bullets showed three and cut the third mid-sentence, and
// the remaining five were not on the slide at all.
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /async function fitSlideText\(bytes\)/);
assert.match(exporter, /<a:bodyPr><a:normAutofit\/><\/a:bodyPr>/);
// No fontScale: the renderer works out the reduction, so a slide that already
// fits is left alone. A fixed scale would shrink every slide regardless.
assert.doesNotMatch(exporter, /normAutofit fontScale/);
// Running it on a deck that already has autofit must not double-inject.
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
// It is now only needed on the pandoc fallback: the Python renderer sizes
// text to fit before writing the file, so its decks never need patching.
assert.match(exporter, /await fsp\.writeFile\(out, await fitSlideText\(await fsp\.readFile\(out\)\)\);/);
// A deck that renders imperfectly beats no deck at all.
assert.match(exporter, /could not apply slide autofit/);
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
});
test('an article is never offered as slides', () => {
const js = read('public/js/myResources.js');
const route = read('src/routes/myResources.js');
// A deck of paragraphs is not a presentation. Word and PDF are fine for
// either; PowerPoint only makes sense for something written as slides.
assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
// The route is the boundary that matters, not the button.
assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
});