Files
Learn_System/backend/tests/lab-links.test.js
Maxim Dolgolyov fb6175e4a2 feat(lab-content-engine): phase 5 завершение — редактор связей в админке + кнопка в учебнике
- 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>
2026-05-30 17:26:35 +03:00

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);
});
});