pediatric-ai-scribe-v3/test/my-resources.test.js
Daniel 571a013d29
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: optional web search, admin-enabled and off by default
The one feature here that sends text outside the building, so the defaults are
the careful ones: disabled unless an administrator turns it on, opt-in per
generation even then, and the option is hidden entirely rather than shown as
something a user can tick and be refused.

Only the search query leaves. Library excerpts, the generated resource and
anything about the user never do. Both screens say so plainly, because a topic
typed while drafting clinical material can carry clinical detail and the
provider keeps its own logs.

Four providers behind one shape, so swapping changes nothing downstream: Tavily,
Serper over Google, Brave, and SearXNG — the only one where the query does not
reach a commercial third party at all, which is why it is worth supporting even
though it needs somewhere to run.

The tool description says when NOT to search, because a model handed a search
tool will reach for it constantly: not for settled clinical knowledge, which is
what the indexed library is for, and one search per resource. That last one is
enforced in the route with toolChoice: 'none' on the continuation rather than
trusted to the model.

A failed search never fails a generation — same contract as corpus retrieval.
The resource is written without it and the response says what was searched for
and what came back, so a query that left the network is visible rather than
silent.

The API key is masked on read and preserved when the field is left blank, the
handling the OIDC client secret already gets, so changing provider cannot
silently wipe a working key.

Verified on the running instance: with nothing configured, webSearchAvailable is
false, and a request asking for it anyway is ignored rather than honoured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 15:47:06 +02:00

178 lines
10 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('a persons own resources are a separate pathway from Learning', () => {
const route = read('src/routes/myResources.js');
const learning = read('src/routes/learningAI.js');
// Learning stays moderator-owned. This exists so that not being a moderator
// no longer means not being able to generate anything at all.
assert.match(learning, /router\.use\(moderatorMiddleware\)/, 'Learning is unchanged');
assert.doesNotMatch(route, /moderatorMiddleware/, 'and this one never mentions it');
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\)/);
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');
assert.match(component, /Private to you/);
assert.match(component, /Nobody else sees 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, and reuses the assistants image tool', () => {
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\(req\.body\.withImages\) === 'true'/);
// Tools are assembled per generation: only what the author asked for.
assert.match(route, /if \(wantsImages && imageModel\) tools = tools\.concat\(imageTool\.tools\);/);
assert.match(route, /if \(wantsWeb\) tools = tools\.concat\(webSearch\.tools\);/);
// The same dispatcher the assistant uses, so an image made here is owned,
// queued and rendered identically to one made there.
assert.match(route, /imageTool\.dispatch\(ai, \{/);
assert.match(route, /workflow: 'my_resources'/, 'but attributed to this feature');
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'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/);
});
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;/);
// And it applies to the PDF path too, which renders from the pptx.
assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
// 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\./);
});