Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The pathway existed but was reachable only by API. It now has a tab of its own next to the Learning Hub — related, not the same thing, and sitting together is how someone discovers the difference — visible to every signed-in user with no role gate in the markup. Generate a deck or an article, see everything you have made, download each as PowerPoint, Word or PDF, delete what you no longer want. The screen says "Private to you" and "Nobody else sees these", because the distinction from published Learning content is the thing a person needs to understand before typing a patient's condition into it. Downloads are fetched rather than linked: an <a href> cannot carry the Authorization header. The blob is saved under the filename the server chose and the object URL is revoked afterwards. Resource titles come from a model, so rows are built as elements and a title is only ever assigned to textContent. The e2e stack now joins danvics_convert too. It could previously reach only Postgres and Redis, so a PDF download failed there in a way production would not — which did at least prove the degradation path works: with Gotenberg unreachable the response is "PDF conversion is unavailable right now. PowerPoint and Word still work", and the other two formats download unaffected. Verified in a browser as an ordinary user: the tab appears and opens, the form swaps slide count for word count when the format changes, the library lists their own work, and pptx, docx and pdf all download with sensible filenames (36360, 13285 and 68310 bytes). Also documents retrieval sizing in docs/retrieval-tuning.md — the per-feature budgets, and RERANKER_TOP_K, which caps all of them and had until now appeared in no configuration file at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
111 lines
6 KiB
JavaScript
111 lines
6 KiB
JavaScript
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 person’s 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 person’s 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 all three formats, and the download carries its auth', () => {
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /\['pptx', 'docx', 'pdf'\]\.forEach/);
|
||
// 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');
|
||
});
|