'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:. 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:') ──────────── */ 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}`); }); }); });