fb6175e4a2
- lab.js: GET /api/lab/links/all?kind= — пакетный обратный поиск (byRef map), чтобы каталог учебников не делал N+1 запросов - tests/lab-links.test.js: +3 теста для /links/all (group/400/401) -> 21/21 - admin/sections/sims.js: inline-редактор курикулумных связей на карточке симуляции (кнопка «Связи» -> панель: список связей с удалением + выбор учебника + добавить); использует /api/access/catalog, POST/DELETE /links. Без LS.modal (inline-панель) - textbooks.html: кнопка «В лабораторию» на карточке учебника, если есть связанные симуляции (один батч-запрос /links/all при загрузке); deep-link /lab?sim=<id> Двусторонняя навигация sim <-> учебник готова. Иконки .ic, без эмодзи. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
194 lines
8.5 KiB
JavaScript
194 lines
8.5 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 colNames = new Set(cols.map(c => c.name));
|
|
// Keep ONLY keys that are real columns (drops fields absent in this schema —
|
|
// robust to drift, e.g. topics may lack slug/subject_id on some branches).
|
|
const row = {};
|
|
for (const k of Object.keys(provided)) if (colNames.has(k)) row[k] = provided[k];
|
|
// Fill any required (NOT NULL, no default) column the caller didn't provide.
|
|
for (const c of cols) {
|
|
if (c.pk) continue;
|
|
if (c.name in row) continue;
|
|
if (c.notnull && c.dflt_value === null) {
|
|
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('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => {
|
|
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.ok(res.body.byRef, 'byRef present');
|
|
assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`);
|
|
assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug');
|
|
});
|
|
|
|
it('batch reverse lookup: bad kind → 400', async () => {
|
|
const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken);
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('batch reverse lookup requires auth (401)', async () => {
|
|
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null);
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|