feat(lab-content-engine): phase 5 - курикулумная привязка симуляций
- Миграция 043_lab_sim_links.sql: таблица связей (sim_id, kind[textbook|topic|
kmap|question], ref_id, label), UNIQUE(sim_id,kind,ref_id) + индексы. Применена.
- lab.js (расширение):
- GET /api/lab/sims/:id/related (auth inline) — связи по типам; label из
textbooks/topics; href для навигации
- GET /api/lab/links?kind=&ref_id= (auth) — обратный поиск включённых
привязанных симуляций (для кнопки «Открыть в лаборатории»)
- POST /api/lab/sims/:id/links (admin), DELETE .../links/:linkId (admin)
- graceful-degradation если таблица ещё не отмигрирована
- tests/lab-links.test.js: 18 тестов (auth/роли/related/reverse/валидация/дубль/
enabled-фильтр/удаление); seedRow() устойчив к NOT NULL дрейфу схемы
- plans: Фаза 5 done + handoff
Все мои тесты: lab-sims 11/11, lab-links 18/18. route-auth: новый :id-роут
защищён inline authMiddleware. Миграция применена к живой БД.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
-- 043_lab_sim_links.sql — Контент-движок лаборатории, Фаза 5 (курикулумная привязка).
|
||||
-- Связи симуляции с учебной программой: § учебника, тема, узел knowledge-map,
|
||||
-- задача банка вопросов. Двусторонняя навигация (sim ↔ контент).
|
||||
--
|
||||
-- kind:
|
||||
-- 'textbook' — ref_id = textbooks.slug
|
||||
-- 'topic' — ref_id = topics.id (как текст)
|
||||
-- 'kmap' — ref_id = id узла графа знаний (свободная строка)
|
||||
-- 'question' — ref_id = questions.id (как текст)
|
||||
-- label — необязательная человекочитаемая подпись (если не резолвится из БД).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_sim_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sim_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- textbook | topic | kmap | question
|
||||
ref_id TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (sim_id, kind, ref_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_sim ON lab_sim_links (sim_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_ref ON lab_sim_links (kind, ref_id);
|
||||
+133
-5
@@ -1,10 +1,14 @@
|
||||
'use strict';
|
||||
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фаза 4).
|
||||
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5).
|
||||
*
|
||||
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги модуля.
|
||||
* Чтение: любой авторизованный пользователь.
|
||||
* PATCH /api/lab/sims/:id — изменить enabled/featured/tags/subject/grade. admin.
|
||||
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
|
||||
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги. auth.
|
||||
* PATCH /api/lab/sims/:id — enabled/featured/tags/subject/grade. admin.
|
||||
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
|
||||
* GET /api/lab/sims/:id/related — связанные § / темы / kmap / задачи. auth. (Ф5)
|
||||
* POST /api/lab/sims/:id/links — добавить связь. admin. (Ф5)
|
||||
* DELETE /api/lab/sims/:id/links/:linkId — удалить связь. admin. (Ф5)
|
||||
* GET /api/lab/links?kind=&ref_id= — обратный поиск: какие симуляции привязаны
|
||||
* к данному учебнику/теме. auth. (Ф5)
|
||||
*
|
||||
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
|
||||
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
|
||||
@@ -14,6 +18,7 @@ const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
|
||||
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
@@ -136,4 +141,127 @@ router.post('/sims/reorder', (req, res) => {
|
||||
res.json({ ok: true, count: order.length });
|
||||
});
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Курикулумная привязка (Фаза 5) — связи симуляции ↔ контент.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
// Безопасно прочитать связи симуляции (если таблицы ещё нет — пустой массив).
|
||||
function readLinks(simId) {
|
||||
try {
|
||||
return db.prepare(
|
||||
'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id'
|
||||
).all(simId);
|
||||
} catch (e) {
|
||||
return null; // null => таблица недоступна (нужна миграция)
|
||||
}
|
||||
}
|
||||
|
||||
// Обогатить связь человекочитаемой меткой и навигационным href.
|
||||
function decorateLink(l) {
|
||||
const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null };
|
||||
if (l.kind === 'textbook') {
|
||||
const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id);
|
||||
if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; }
|
||||
out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id);
|
||||
} else if (l.kind === 'topic') {
|
||||
const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id));
|
||||
if (tp) out.label = out.label || tp.name;
|
||||
} else if (l.kind === 'question') {
|
||||
out.href = null; // задачи открываются в банке вопросов отдельным контекстом
|
||||
}
|
||||
if (!out.label) out.label = l.kind + ':' + l.ref_id;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* GET /api/lab/sims/:id/related → { sim, links:{ textbook:[], topic:[], kmap:[], question:[] } } */
|
||||
router.get('/sims/:id/related', authMiddleware, (req, res) => {
|
||||
const id = String(req.params.id || '');
|
||||
const sim = db.prepare('SELECT id, title FROM lab_sims WHERE id = ?').get(id);
|
||||
// sim может отсутствовать в lab_sims (если миграция 042 не применена) — не 404,
|
||||
// т.к. связи всё равно могут существовать; вернём то, что есть.
|
||||
const rows = readLinks(id);
|
||||
if (rows === null) return res.json({ sim: sim || { id }, links: {}, needs_migration: true });
|
||||
const links = { textbook: [], topic: [], kmap: [], question: [] };
|
||||
for (const l of rows) {
|
||||
const d = decorateLink(l);
|
||||
(links[l.kind] || (links[l.kind] = [])).push(d);
|
||||
}
|
||||
res.json({ sim: sim || { id }, links });
|
||||
});
|
||||
|
||||
/* GET /api/lab/links?kind=textbook&ref_id=algebra-8
|
||||
→ { sims:[{id,title,cat,enabled}] } — какие (включённые) симуляции привязаны. */
|
||||
router.get('/links', (req, res) => {
|
||||
const kind = String(req.query.kind || '');
|
||||
const refId = String(req.query.ref_id || '');
|
||||
if (!LINK_KINDS.includes(kind) || !refId) {
|
||||
return res.status(400).json({ error: 'kind и ref_id обязательны' });
|
||||
}
|
||||
let rows;
|
||||
try {
|
||||
rows = db.prepare(`
|
||||
SELECT s.id, s.title, s.cat, s.enabled
|
||||
FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id
|
||||
WHERE l.kind = ? AND l.ref_id = ?
|
||||
ORDER BY s.sort_order, s.id
|
||||
`).all(kind, refId);
|
||||
} catch (e) {
|
||||
return res.json({ sims: [], needs_migration: true });
|
||||
}
|
||||
const legacyDisabled = readLegacyDisabledIds();
|
||||
const sims = rows
|
||||
.map(r => ({ id: r.id, title: r.title, cat: r.cat, enabled: !!r.enabled && !legacyDisabled.has(r.id) }))
|
||||
.filter(s => s.enabled); // наружу отдаём только доступные
|
||||
res.json({ sims });
|
||||
});
|
||||
|
||||
/* ── admin: управление связями ─────────────────────────────────────────── */
|
||||
|
||||
/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */
|
||||
router.post('/sims/:id/links', requireRole('admin'), (req, res) => {
|
||||
const simId = String(req.params.id || '');
|
||||
if (!db.prepare('SELECT 1 FROM lab_sims WHERE id = ?').get(simId)) {
|
||||
return res.status(404).json({ error: 'симуляция не найдена' });
|
||||
}
|
||||
const b = req.body || {};
|
||||
const kind = String(b.kind || '');
|
||||
const refId = String(b.ref_id || '').trim();
|
||||
if (!LINK_KINDS.includes(kind)) return res.status(400).json({ error: 'неверный kind' });
|
||||
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
|
||||
|
||||
// Валидация существования цели (мягкая — kmap/question произвольны).
|
||||
if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) {
|
||||
return res.status(404).json({ error: 'учебник не найден: ' + refId });
|
||||
}
|
||||
if (kind === 'topic') {
|
||||
const tid = Number(refId);
|
||||
if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) {
|
||||
return res.status(404).json({ error: 'тема не найдена: ' + refId });
|
||||
}
|
||||
}
|
||||
|
||||
const label = b.label != null ? String(b.label).trim().slice(0, 200) || null : null;
|
||||
try {
|
||||
const info = db.prepare(
|
||||
'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(simId, kind, refId, label, req.user.id);
|
||||
const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?')
|
||||
.get(info.lastInsertRowid);
|
||||
res.json({ ok: true, link: decorateLink(created) });
|
||||
} catch (e) {
|
||||
if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
/* DELETE /api/lab/sims/:id/links/:linkId */
|
||||
router.delete('/sims/:id/links/:linkId', requireRole('admin'), (req, res) => {
|
||||
const simId = String(req.params.id || '');
|
||||
const linkId = Number(req.params.linkId);
|
||||
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
|
||||
const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId);
|
||||
if (!info.changes) return res.status(404).json({ error: 'связь не найдена' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
'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.
|
||||
db.prepare(`INSERT INTO textbooks (slug, title, subject, grade, is_active)
|
||||
VALUES ('phys-test', 'Физика тест', 'physics', 9, 1)
|
||||
ON CONFLICT(slug) DO NOTHING`).run();
|
||||
tbSlug = 'phys-test';
|
||||
const subj = db.prepare(`INSERT INTO subjects (name) VALUES ('LinkTest Subj')`).run();
|
||||
const tp = db.prepare(`INSERT INTO topics (subject_id, name) VALUES (?, 'Колебания тест')`)
|
||||
.run(subj.lastInsertRowid);
|
||||
topicId = tp.lastInsertRowid;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user