15c74f5aa8
GET /related и /links возвращали 200 без токена: они были ПОСЛЕ blanket
router.use(requireRole('admin')) (хрупкий порядок при повторном mount роутера
в тестах). Убрал blanket; каждая мутация (patch/reorder/links POST+DELETE)
имеет INLINE requireRole('admin'); read-роуты — auth-only.
Также lab-links seed переведён на seedRow() (NOT NULL дрейф схемы).
lab-links 18/18, lab-sims 11/11, route-auth: 0 роутов lab.js во флаге.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
7.4 KiB
JavaScript
171 lines
7.4 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Integration tests: /api/lab — curriculum links (Phase 5).
|
|
* Covers: related (auth), reverse lookup, admin add/delete, validation,
|
|
* role-gating, textbook/topic existence checks, enabled-filtering of reverse.
|
|
*/
|
|
const { describe, it, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
|
|
|
// Mount /api/lab on the shared test app.
|
|
app.use('/api/lab', require('../src/routes/lab'));
|
|
|
|
after(() => cleanup());
|
|
|
|
/**
|
|
* Schema-robust insert: fills every NOT NULL column (without a default) that the
|
|
* caller didn't provide with a safe placeholder, then inserts. Returns lastInsertRowid.
|
|
* Protects the seed from schema drift (e.g. textbooks.html_path NOT NULL) introduced
|
|
* by parallel sessions on this branch.
|
|
*/
|
|
function seedRow(table, provided) {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
const row = { ...provided };
|
|
for (const c of cols) {
|
|
if (c.pk) continue; // skip primary key (autoincrement)
|
|
if (c.name in row) continue; // caller-provided
|
|
if (c.notnull && c.dflt_value === null) { // required, no default → fill placeholder
|
|
row[c.name] = /INT|REAL|NUM/i.test(c.type) ? 0 : '';
|
|
}
|
|
}
|
|
const names = Object.keys(row);
|
|
const ph = names.map(() => '?').join(', ');
|
|
const info = db.prepare(`INSERT INTO ${table} (${names.join(', ')}) VALUES (${ph})`)
|
|
.run(...names.map(n => row[n]));
|
|
return info.lastInsertRowid;
|
|
}
|
|
|
|
describe('/api/lab curriculum links', () => {
|
|
let adminToken, studentToken, tbSlug, topicId;
|
|
|
|
before(async () => {
|
|
adminToken = (await getToken('admin')).token;
|
|
studentToken = (await getToken('student')).token;
|
|
// Seed a textbook + topic to link against (schema-robust — fills NOT NULL cols).
|
|
tbSlug = 'phys-test';
|
|
seedRow('textbooks', { slug: tbSlug, title: 'Физика тест', subject: 'physics', grade: 9, is_active: 1 });
|
|
const subjId = seedRow('subjects', { name: 'LinkTest Subj', slug: 'linktest-subj' });
|
|
topicId = seedRow('topics', { subject_id: subjId, name: 'Колебания тест', slug: 'kolebaniya-test' });
|
|
});
|
|
|
|
it('GET /related requires auth (401)', async () => {
|
|
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, null);
|
|
assert.equal(res.status, 401, `got ${res.status}`);
|
|
});
|
|
|
|
it('GET /related returns empty link buckets for a sim with no links', async () => {
|
|
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.sim.id, 'pendulum');
|
|
assert.deepEqual(res.body.links.textbook, []);
|
|
assert.deepEqual(res.body.links.topic, []);
|
|
});
|
|
|
|
it('POST /links is admin-only (student → 403)', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'textbook', ref_id: tbSlug }, studentToken);
|
|
assert.equal(res.status, 403, `got ${res.status}`);
|
|
});
|
|
|
|
it('admin can add a textbook link; label resolved from textbooks', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.link.kind, 'textbook');
|
|
assert.equal(res.body.link.ref_id, tbSlug);
|
|
assert.equal(res.body.link.label, 'Физика тест', 'label resolved from textbook title');
|
|
assert.ok(res.body.link.href.includes(tbSlug), 'href points to textbook');
|
|
});
|
|
|
|
it('related now shows the textbook link', async () => {
|
|
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.links.textbook.length, 1);
|
|
assert.equal(res.body.links.textbook[0].ref_id, tbSlug);
|
|
});
|
|
|
|
it('admin can add a topic link; label resolved from topics', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'topic', ref_id: String(topicId) }, adminToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.link.label, 'Колебания тест');
|
|
});
|
|
|
|
it('duplicate link → 409', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
|
|
assert.equal(res.status, 409, `got ${res.status}`);
|
|
});
|
|
|
|
it('validation: bad kind → 400', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'nope', ref_id: 'x' }, adminToken);
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('validation: missing ref_id → 400', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'textbook' }, adminToken);
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('validation: unknown textbook → 404', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'textbook', ref_id: 'ghost-book' }, adminToken);
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
it('validation: unknown topic → 404', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/pendulum/links',
|
|
{ kind: 'topic', ref_id: '999999' }, adminToken);
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
it('POST link to unknown sim → 404', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/ghostsim/links',
|
|
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
it('reverse lookup: GET /links?kind=textbook&ref_id= returns linked enabled sims', async () => {
|
|
const res = await inject('GET',
|
|
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.ok(res.body.sims.some(s => s.id === 'pendulum'), 'pendulum in reverse lookup');
|
|
});
|
|
|
|
it('reverse lookup excludes disabled sims', async () => {
|
|
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: false }, adminToken);
|
|
const res = await inject('GET',
|
|
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
|
|
assert.ok(!res.body.sims.some(s => s.id === 'pendulum'), 'disabled pendulum excluded');
|
|
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore
|
|
});
|
|
|
|
it('reverse lookup: bad kind → 400', async () => {
|
|
const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken);
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('admin can delete a link; related reflects removal', async () => {
|
|
// find the textbook link id
|
|
const rel = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
|
|
const linkId = rel.body.links.textbook[0].id;
|
|
const del = await inject('DELETE', `/api/lab/sims/pendulum/links/${linkId}`, null, adminToken);
|
|
assert.equal(del.status, 200, `got ${del.status}`);
|
|
const rel2 = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
|
|
assert.equal(rel2.body.links.textbook.length, 0, 'textbook link gone');
|
|
});
|
|
|
|
it('delete unknown link → 404', async () => {
|
|
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/999999', null, adminToken);
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
it('delete is admin-only (student → 403)', async () => {
|
|
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/1', null, studentToken);
|
|
assert.equal(res.status, 403);
|
|
});
|
|
});
|