pediatric-ai-scribe-v3/test/resource-to-nextcloud.test.js
Daniel 24c8d71b7e
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: send a resource to your own Nextcloud, as the rendered file
A .pptx landing in someone's own storage is worth having; a markdown blob is
not, and it is not what they would have downloaded. So this renders through
exactly the path the download uses — stored deck, its figures, the chosen
theme — and PUTs the bytes. The file never travels through the browser.

Offered only when a Nextcloud is connected: an action that always fails is
worse than one that is not offered. An article offers Word, a deck PowerPoint,
and asking for slides from an article is refused with the reason.

Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two
callers want it and neither should grow its own copy of the WebDAV dance — make
the dated folder a segment at a time, PUT, migrate a legacy plaintext token —
because it reaches into storage that is not ours and a second slightly
different copy is how the two drift. It also replaces a route importing another
route.

Also: a model that leaves the roster now leaves every list that names it.
clinical_assistant.allowed_models and the image roster are advisory copies of
the roster, and a stale id there was invisible until someone asked a clinical
question and the request failed at the gateway. Removing or disabling a model
prunes it; clearing the roster clears them. Re-enabling deliberately does not
re-allow it — that is a separate decision.

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

67 lines
3.5 KiB
JavaScript

// Sending a resource to Nextcloud pushes the rendered file, not the markdown.
// A .pptx landing in someone's own storage is the thing worth having; a text
// blob is not, and it is not what they would have downloaded.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
const route = read('src/routes/myResources.js');
const util = read('src/utils/nextcloudFiles.js');
const ui = read('public/js/myResources.js');
test('the file is rendered by the same path a download uses', () => {
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2200), /documentExport\.render\(row\.markdown, row\.kind, format/);
assert.match(handler.slice(0, 2200), /deck: deck/, 'the stored deck, so it renders as designed');
assert.match(handler.slice(0, 2200), /collectFigures\(row\.image_ids/, 'with its figures');
// Never the markdown on its own.
assert.doesNotMatch(handler.slice(0, 2200), /send\([^)]*row\.markdown/);
});
test('it is scoped to the owner and refuses a format the resource cannot be', () => {
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2200), /WHERE id = \? AND user_id = \?/);
assert.match(handler.slice(0, 2200), /An article has no slides/);
assert.match(handler.slice(0, 2200), /documentExport\.isSupported\(format\)/);
});
test('a disconnected Nextcloud says so, rather than failing as a server error', () => {
assert.match(util, /Nextcloud is not connected\. Connect it in Settings\./);
assert.match(util, /Nextcloud credentials are invalid\. Reconnect in Settings\./);
// Those carry a status the route passes through instead of flattening to 502.
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2400), /if \(err\.statusCode\) return res\.status\(err\.statusCode\)/);
});
test('one module knows how to put a file in Nextcloud', () => {
// A route importing another route is what this replaced.
assert.match(route, /require\('\.\.\/utils\/nextcloudFiles'\)/);
assert.doesNotMatch(route, /require\('\.\/nextcloud'\)/);
assert.match(util, /async function send\(userId, name, bytes, contentType\)/);
});
test('the filename cannot traverse or confuse a filesystem', () => {
assert.match(util, /replace\(\/\[\^a-zA-Z0-9\._-\]\/g, '_'\)/);
assert.match(util, /\.slice\(0, 120\) \|\| 'resource'/, 'and cannot end up empty');
});
test('the folder tree is made a segment at a time, and an existing one is not an error', () => {
// WebDAV will not create a tree in one call; MKCOL on an existing folder is 405.
assert.match(util, /for \(var part of folder\.split\('\/'\)\.filter\(Boolean\)\)/);
assert.match(util, /already there, or the PUT below will report the real fault/);
});
test('the button appears only when there is a Nextcloud to send to', () => {
assert.match(route, /nextcloudConnected: Boolean/);
assert.match(ui, /if \(nextcloudConnected\) \{/);
assert.match(ui, /data-nextcloud|dataset\.nextcloud/);
// An article offers Word, a deck offers PowerPoint.
assert.match(ui, /row\.kind === 'article' \? 'docx' : 'pptx'/);
});
test('a legacy plaintext token is encrypted on first successful use', () => {
assert.match(util, /if \(!cryptoUtil\.isEncrypted\(place\.user\.nextcloud_token\)\)/);
assert.match(util, /cryptoUtil\.encryptString\(place\.password\)/);
});