feat(sim-builder): фаза 6 — раздача классу, клон, шаблоны, привязка к программе (custom_sims)
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
* per-row ownership на каждой мутации, статусы 400/403/404.
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
|
||||
/* ── Лимиты валидации спеки ──────────────────────────────────────────── */
|
||||
const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON
|
||||
@@ -333,7 +334,188 @@ function remove(req, res) {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id);
|
||||
// Заодно чистим осиротевшие курикулумные связи (sim_id = 'custom:<id>').
|
||||
try { db.prepare("DELETE FROM lab_sim_links WHERE sim_id = ?").run('custom:' + req.params.id); } catch (_e) { /* таблица может отсутствовать */ }
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { list, get, create, update, remove, validateSpec };
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Фаза 6 — раздача классу / клонирование / курикулумная привязка.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Проверка владения симуляцией (владелец ИЛИ admin). Возвращает row или null. */
|
||||
function ownedSim(req) {
|
||||
const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!row) return { row: null, code: 404 };
|
||||
if (row.owner_id !== req.user.id && req.user.role !== 'admin') return { row: null, code: 403 };
|
||||
return { row, code: 200 };
|
||||
}
|
||||
|
||||
/* POST /api/custom-sims/:id/share body: { classId }
|
||||
*
|
||||
* РЕШЕНИЕ (копия vs доступ): published custom-sim И ТАК видна всем в каталоге
|
||||
* /lab (list/get отдают published любому). Поэтому раздача классу — это НЕ копия
|
||||
* (как у «Моих материалов», где копия нужна т.к. оригинал приватный), а:
|
||||
* 1) авто-публикация (status -> published), чтобы ученики могли открыть;
|
||||
* 2) адресное ДОЛГОВЕЧНОЕ уведомление ученикам класса со ссылкой
|
||||
* /lab?sim=custom:<id> (notifications-таблица + SSE через pushNotif).
|
||||
* Отдельная запись content_access не нужна: custom-sim не гейтится allowlist'ом
|
||||
* 'sim' (тот гейтит только legacy lab_sims); published виден всем. */
|
||||
function share(req, res) {
|
||||
const { row, code } = ownedSim(req);
|
||||
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||
|
||||
const b = req.body || {};
|
||||
const classId = Number(b.classId);
|
||||
if (!Number.isFinite(classId)) return res.status(400).json({ error: 'classId required' });
|
||||
|
||||
const cls = db.prepare('SELECT id, teacher_id, name FROM classes WHERE id = ?').get(classId);
|
||||
if (!cls) return res.status(404).json({ error: 'class not found' });
|
||||
if (cls.teacher_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'not your class' });
|
||||
}
|
||||
|
||||
// Авто-публикация, чтобы ученики могли открыть симуляцию по ссылке.
|
||||
if (row.status !== 'published') {
|
||||
db.prepare("UPDATE custom_sims SET status = 'published', updated_at = datetime('now') WHERE id = ?").run(row.id);
|
||||
}
|
||||
|
||||
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||
const simTitle = row.title || 'симуляция';
|
||||
const link = '/lab?sim=custom:' + row.id;
|
||||
const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id);
|
||||
|
||||
let sent = 0;
|
||||
for (const uid of recipients) {
|
||||
if (!uid || uid === req.user.id) continue;
|
||||
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link);
|
||||
sent++;
|
||||
}
|
||||
res.json({ ok: true, sent, status: 'published' });
|
||||
}
|
||||
|
||||
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
|
||||
* Источник: своя (любой статус) ИЛИ чужая published. Заголовок += « (копия)».
|
||||
* Метаданные (subject/grade/cat) копируются; status всегда draft; version=1. */
|
||||
function clone(req, res) {
|
||||
const src = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!src) return res.status(404).json({ error: 'not found' });
|
||||
// Клонировать можно свою (любую) или чужую только published.
|
||||
if (src.owner_id !== req.user.id && src.status !== 'published' && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
|
||||
const baseTitle = src.title || 'Симуляция';
|
||||
const title = sanitizeText(baseTitle + ' (копия)');
|
||||
const r = db.prepare(`
|
||||
INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', 1)
|
||||
`).run(
|
||||
req.user.id,
|
||||
title,
|
||||
src.description,
|
||||
src.subject,
|
||||
src.grade,
|
||||
src.cat,
|
||||
src.spec_json,
|
||||
);
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||
}
|
||||
|
||||
/* ── Курикулумная привязка: переиспользуем lab_sim_links с sim_id='custom:<id>'.
|
||||
sim_id в таблице — TEXT, поэтому отдельная таблица не нужна. Управляет
|
||||
связями ВЛАДЕЛЕЦ симуляции (или admin), а не только admin как у lab_sims:
|
||||
custom-sim принадлежит учителю. ───────────────────────────────────────── */
|
||||
|
||||
const LINK_KINDS = new Set(['textbook', 'topic', 'kmap', 'question']);
|
||||
|
||||
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;
|
||||
}
|
||||
if (!out.label) out.label = l.kind + ':' + l.ref_id;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* GET /api/custom-sims/:id/related → { links:{ textbook:[], topic:[], kmap:[], question:[] } }
|
||||
Доступно любому, кто может видеть симуляцию (own ИЛИ published). */
|
||||
function related(req, res) {
|
||||
const sim = db.prepare('SELECT id, owner_id, status FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!sim) return res.status(404).json({ error: 'not found' });
|
||||
if (sim.owner_id !== req.user.id && sim.status !== 'published' && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
const simId = 'custom:' + sim.id;
|
||||
let rows;
|
||||
try {
|
||||
rows = 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 res.json({ links: {}, needs_migration: true });
|
||||
}
|
||||
const links = { textbook: [], topic: [], kmap: [], question: [] };
|
||||
for (const l of rows) (links[l.kind] || (links[l.kind] = [])).push(decorateLink(l));
|
||||
res.json({ links });
|
||||
}
|
||||
|
||||
/* POST /api/custom-sims/:id/links body: { kind, ref_id, label? } — добавить связь.
|
||||
Владелец/admin. */
|
||||
function addLink(req, res) {
|
||||
const { row, code } = ownedSim(req);
|
||||
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||
|
||||
const b = req.body || {};
|
||||
const kind = String(b.kind || '');
|
||||
const refId = String(b.ref_id || '').trim();
|
||||
if (!LINK_KINDS.has(kind)) return res.status(400).json({ error: 'неверный kind' });
|
||||
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
|
||||
|
||||
// Мягкая валидация существования цели (как в lab.js).
|
||||
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;
|
||||
const simId = 'custom:' + row.id;
|
||||
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/custom-sims/:id/links/:linkId — удалить связь. Владелец/admin. */
|
||||
function removeLink(req, res) {
|
||||
const { row, code } = ownedSim(req);
|
||||
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||
const linkId = Number(req.params.linkId);
|
||||
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
|
||||
const simId = 'custom:' + row.id;
|
||||
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 = {
|
||||
list, get, create, update, remove, validateSpec,
|
||||
share, clone, related, addLink, removeLink,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ router.use(authMiddleware);
|
||||
router.get('/', c.list);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id', c.get);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id/related', c.related);
|
||||
|
||||
router.post('/', requireRole('teacher', 'admin'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
@@ -20,4 +22,13 @@ router.put('/:id', requireRole('teacher', 'admin'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
|
||||
// requireRole(teacher,admin) + per-row ownership в хендлере.
|
||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/custom-sims — раздача / клон / курикулумная привязка (Фаза 6).
|
||||
* Covers:
|
||||
* share — ученик класса получает ДОЛГОВЕЧНОЕ уведомление, sim авто-публикуется;
|
||||
* раздача не своего класса / чужого draft → 403; неизвестный класс → 404.
|
||||
* clone — новый владелец, status=draft, spec скопирован, title += « (копия)»;
|
||||
* чужой published клонируется ОК; чужой draft → 403.
|
||||
* links — владелец привязывает учебник; чужой draft → 403; published related ОК.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
|
||||
app.use('/api/custom-sims', require('../src/routes/customSims'));
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
const VALID_SPEC = {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Маятник' },
|
||||
viewport: { xmin: -5, xmax: 5, ymin: -5, ymax: 1 },
|
||||
params: [{ name: 'L', label: 'Длина', min: 0.5, max: 3, step: 0.1, value: 1.5 }],
|
||||
objects: [{ id: 'bob', type: 'circle', x: 'L*sin(t)', y: '-L*cos(t)', r: 0.2, color: '#9B5DE5' }],
|
||||
};
|
||||
|
||||
/* seedClass(teacherId, [studentIds]) → classId. Прямая вставка (seedRow-паттерн). */
|
||||
function seedClass(teacherId, studentIds) {
|
||||
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
|
||||
const r = db.prepare(
|
||||
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
|
||||
).run('Класс ' + code, teacherId, code);
|
||||
const classId = Number(r.lastInsertRowid);
|
||||
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of studentIds) ins.run(classId, uid);
|
||||
return classId;
|
||||
}
|
||||
|
||||
async function createSim(token, overrides) {
|
||||
const res = await inject('POST', '/api/custom-sims',
|
||||
Object.assign({ title: 'Маятник', cat: 'phys', spec: VALID_SPEC }, overrides || {}), token);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('/api/custom-sims (Фаза 6: share / clone / links)', () => {
|
||||
let teacher, otherTeacher, student, studentB, admin;
|
||||
|
||||
before(async () => {
|
||||
teacher = await getToken('teacher');
|
||||
otherTeacher = await getToken('teacher');
|
||||
student = await getToken('student');
|
||||
studentB = await getToken('student');
|
||||
admin = await getToken('admin');
|
||||
});
|
||||
|
||||
/* ── SHARE ──────────────────────────────────────────────────────────── */
|
||||
describe('share', () => {
|
||||
it('teacher shares a DRAFT sim to own class → 200, auto-publish, students notified', async () => {
|
||||
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
|
||||
const c = await createSim(teacher.token); // draft by default
|
||||
assert.equal(c.status, 201);
|
||||
const simId = c.body.id;
|
||||
|
||||
const before = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||
assert.equal(before.status, 'draft', 'created as draft');
|
||||
|
||||
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
|
||||
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
assert.equal(res.body.sent, 2, 'two students notified');
|
||||
assert.equal(res.body.status, 'published', 'reports published');
|
||||
|
||||
// Авто-публикация в БД.
|
||||
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||
assert.equal(after.status, 'published', 'sim auto-published');
|
||||
|
||||
// Долговечное уведомление со ссылкой /lab?sim=custom:<id>.
|
||||
const notif = db.prepare(
|
||||
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'sim_shared' ORDER BY id DESC"
|
||||
).get(student.userId);
|
||||
assert.ok(notif, 'student has a sim_shared notification');
|
||||
assert.equal(notif.link, '/lab?sim=custom:' + simId, 'notification links to the sim');
|
||||
});
|
||||
|
||||
it('share requires classId (400)', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, {}, teacher.token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('share to unknown class → 404', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId: 99999 }, teacher.token);
|
||||
assert.equal(res.status, 404, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it("share to a class that isn't yours → 403", async () => {
|
||||
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||
const c = await createSim(teacher.token); // teacher owns the sim
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, teacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it("non-owner cannot share someone else's DRAFT (403)", async () => {
|
||||
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||
const c = await createSim(teacher.token); // owned by teacher, draft
|
||||
// otherTeacher tries to share teacher's draft to otherTeacher's own class.
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, otherTeacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('student cannot share (role-gated 403)', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const classId = seedClass(teacher.userId, [student.userId]);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, student.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── CLONE ──────────────────────────────────────────────────────────── */
|
||||
describe('clone', () => {
|
||||
it('owner clones own sim → new draft owned by caller, spec copied, title += копия', async () => {
|
||||
const c = await createSim(teacher.token, { title: 'Оригинал', subject: 'physics', grade: 9 });
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, teacher.token);
|
||||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
const newId = res.body.id;
|
||||
assert.notEqual(newId, c.body.id, 'new row');
|
||||
|
||||
const get = await inject('GET', `/api/custom-sims/${newId}`, null, teacher.token);
|
||||
const s = get.body.sim;
|
||||
assert.equal(s.owner_id, teacher.userId, 'caller owns the clone');
|
||||
assert.equal(s.status, 'draft', 'clone is draft');
|
||||
assert.equal(s.version, 1, 'clone version reset to 1');
|
||||
assert.equal(s.title, 'Оригинал (копия)', 'title gets (копия)');
|
||||
assert.equal(s.subject, 'physics');
|
||||
assert.equal(s.grade, 9);
|
||||
assert.equal(s.spec.objects.length, VALID_SPEC.objects.length, 'spec copied');
|
||||
assert.equal(s.spec.objects[0].id, 'bob', 'spec content copied');
|
||||
});
|
||||
|
||||
it('teacher clones ANOTHER teacher PUBLISHED sim → 201 (now owned by cloner, draft)', async () => {
|
||||
// teacher creates + publishes.
|
||||
const c = await createSim(teacher.token, { status: 'published' });
|
||||
assert.equal(c.status, 201);
|
||||
const src = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(c.body.id);
|
||||
assert.equal(src.status, 'published');
|
||||
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, otherTeacher.token);
|
||||
assert.equal(get.body.sim.owner_id, otherTeacher.userId);
|
||||
assert.equal(get.body.sim.status, 'draft');
|
||||
});
|
||||
|
||||
it("teacher CANNOT clone another teacher's DRAFT (403)", async () => {
|
||||
const c = await createSim(teacher.token); // draft
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('clone of unknown id → 404', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims/99999/clone', null, teacher.token);
|
||||
assert.equal(res.status, 404, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── CURRICULUM LINKS (lab_sim_links, sim_id='custom:<id>') ──────────── */
|
||||
describe('links', () => {
|
||||
let bookSlug;
|
||||
before(() => {
|
||||
// Засеять учебник для привязки (textbooks.slug — ref_id для kind=textbook).
|
||||
bookSlug = 'phys-test-' + Math.random().toString(36).slice(2, 7);
|
||||
db.prepare(
|
||||
'INSERT INTO textbooks (slug, title, subject, grade, html_path, is_active) VALUES (?, ?, ?, ?, ?, 1)'
|
||||
).run(bookSlug, 'Физика тест', 'physics', 9, bookSlug + '.html');
|
||||
});
|
||||
|
||||
it('owner links own sim to a textbook, related lists it', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: bookSlug }, teacher.token);
|
||||
assert.equal(add.status, 200, `got ${add.status}: ${JSON.stringify(add.body)}`);
|
||||
assert.equal(add.body.link.kind, 'textbook');
|
||||
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||
assert.equal(rel.status, 200);
|
||||
assert.equal(rel.body.links.textbook.length, 1, 'one textbook link');
|
||||
assert.equal(rel.body.links.textbook[0].ref_id, bookSlug);
|
||||
|
||||
// Удаление связи.
|
||||
const linkId = add.body.link.id;
|
||||
const del = await inject('DELETE', `/api/custom-sims/${c.body.id}/links/${linkId}`, null, teacher.token);
|
||||
assert.equal(del.status, 200);
|
||||
const rel2 = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||
assert.equal(rel2.body.links.textbook.length, 0, 'link removed');
|
||||
});
|
||||
|
||||
it('linking to unknown textbook → 404', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: 'no-such-book' }, teacher.token);
|
||||
assert.equal(add.status, 404, `got ${add.status}`);
|
||||
});
|
||||
|
||||
it("non-owner CANNOT add link to someone else's draft (403)", async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: bookSlug }, otherTeacher.token);
|
||||
assert.equal(add.status, 403, `got ${add.status}`);
|
||||
});
|
||||
|
||||
it('related on a published sim is readable by any user (student)', async () => {
|
||||
const c = await createSim(teacher.token, { status: 'published' });
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, student.token);
|
||||
assert.equal(rel.status, 200, `got ${rel.status}`);
|
||||
assert.ok(rel.body.links, 'links object present');
|
||||
});
|
||||
|
||||
it("related on someone else's draft → 403 for non-owner", async () => {
|
||||
const c = await createSim(teacher.token); // draft
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, otherTeacher.token);
|
||||
assert.equal(rel.status, 403, `got ${rel.status}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user