c1c5bafaff
- Миграция 042_lab_sims.sql: таблица lab_sims (id, cat, title, subject, grade,
sort_order, enabled, featured, tags JSON), сид 40 симуляций в порядке каталога
- backend/src/routes/lab.js: GET /api/lab/sims (мёрж БД + legacy-флаги, auth),
PATCH /api/lab/sims/:id (admin), POST /api/lab/sims/reorder (admin).
enabled зеркалится в legacy sim_disabled_ids -> lab.html без правок фронта
- server.js: монтирование /api/lab
- tests/lab-sims.test.js: 11 тестов (auth/роли/вкл-выкл+зеркало/featured/tags/
валидация/reorder/404), все проходят; +0 к baseline (3 pre-existing)
- admin/sections/sims.js: убран захардкоженный ADMIN_SIMS, каталог из /api/lab/sims,
тумблеры вкл-выкл и «рекомендуемая»; XSS-эскейп, иконки .ic
- plans/: Фаза 4 done + handoff
Независимое ревью: PASS, блокеров нет. route-auth lint: PATCH-роут защищён inline
requireRole('admin'). Миграция применена к живой БД.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
5.6 KiB
JavaScript
123 lines
5.6 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Integration tests: /api/lab/sims — catalog from DB + admin overrides.
|
|
* Covers: seeded catalog, auth, role-gating, enabled toggle (+legacy mirror),
|
|
* featured/tags/subject/grade patch, reorder, validation.
|
|
*/
|
|
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 (setup builds its own app without it).
|
|
app.use('/api/lab', require('../src/routes/lab'));
|
|
|
|
after(() => cleanup());
|
|
|
|
describe('/api/lab/sims', () => {
|
|
let adminToken, studentToken;
|
|
|
|
before(async () => {
|
|
adminToken = (await getToken('admin')).token;
|
|
studentToken = (await getToken('student')).token;
|
|
});
|
|
|
|
it('GET /api/lab/sims requires auth (401 without token)', async () => {
|
|
const res = await inject('GET', '/api/lab/sims', null, null);
|
|
assert.equal(res.status, 401, `got ${res.status}`);
|
|
});
|
|
|
|
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
|
|
const res = await inject('GET', '/api/lab/sims', null, studentToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.module_disabled, false);
|
|
assert.ok(Array.isArray(res.body.sims), 'sims is array');
|
|
assert.equal(res.body.sims.length, 40, `expected 40 sims, got ${res.body.sims.length}`);
|
|
const pend = res.body.sims.find(s => s.id === 'pendulum');
|
|
assert.ok(pend, 'pendulum present');
|
|
assert.equal(pend.cat, 'phys');
|
|
assert.equal(pend.enabled, true);
|
|
assert.deepEqual(pend.tags, []);
|
|
});
|
|
|
|
it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => {
|
|
const res = await inject('GET', '/api/lab/sims', null, studentToken);
|
|
assert.equal(res.body.sims[0].id, 'graph');
|
|
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
|
|
});
|
|
|
|
it('PATCH /api/lab/sims/:id is admin-only (student → 403)', async () => {
|
|
const res = await inject('PATCH', '/api/lab/sims/pendulum', { featured: true }, studentToken);
|
|
assert.equal(res.status, 403, `got ${res.status}`);
|
|
});
|
|
|
|
it('admin can disable a sim; it reflects in GET and in legacy sim_disabled_ids', async () => {
|
|
const res = await inject('PATCH', '/api/lab/sims/waves', { enabled: false }, adminToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.sim.enabled, false);
|
|
|
|
const get = await inject('GET', '/api/lab/sims', null, adminToken);
|
|
const waves = get.body.sims.find(s => s.id === 'waves');
|
|
assert.equal(waves.enabled, false, 'waves disabled in catalog');
|
|
|
|
const legacy = JSON.parse(
|
|
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
|
|
);
|
|
assert.ok(legacy.includes('waves'), 'waves in legacy sim_disabled_ids');
|
|
|
|
await inject('PATCH', '/api/lab/sims/waves', { enabled: true }, adminToken);
|
|
const legacy2 = JSON.parse(
|
|
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
|
|
);
|
|
assert.ok(!legacy2.includes('waves'), 'waves removed from legacy after enable');
|
|
});
|
|
|
|
it('admin can set featured, tags, subject, grade', async () => {
|
|
const res = await inject('PATCH', '/api/lab/sims/pendulum',
|
|
{ featured: true, tags: ['колебания', 'механика'], subject: 'physics', grade: 9 }, adminToken);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.sim.featured, true);
|
|
assert.deepEqual(res.body.sim.tags, ['колебания', 'механика']);
|
|
assert.equal(res.body.sim.subject, 'physics');
|
|
assert.equal(res.body.sim.grade, 9);
|
|
});
|
|
|
|
it('PATCH rejects bad grade and bad category and non-array tags', async () => {
|
|
const g = await inject('PATCH', '/api/lab/sims/pendulum', { grade: 99 }, adminToken);
|
|
assert.equal(g.status, 400, 'bad grade rejected');
|
|
const c = await inject('PATCH', '/api/lab/sims/pendulum', { cat: 'nope' }, adminToken);
|
|
assert.equal(c.status, 400, 'bad cat rejected');
|
|
const t = await inject('PATCH', '/api/lab/sims/pendulum', { tags: 'notarray' }, adminToken);
|
|
assert.equal(t.status, 400, 'non-array tags rejected');
|
|
});
|
|
|
|
it('PATCH unknown sim → 404', async () => {
|
|
const res = await inject('PATCH', '/api/lab/sims/nonexistent', { featured: true }, adminToken);
|
|
assert.equal(res.status, 404, `got ${res.status}`);
|
|
});
|
|
|
|
it('POST /api/lab/sims/reorder updates sort order (admin)', async () => {
|
|
const get = await inject('GET', '/api/lab/sims', null, adminToken);
|
|
const ids = get.body.sims.map(s => s.id);
|
|
const reordered = ['angrybirds', 'graph', ...ids.filter(id => id !== 'angrybirds' && id !== 'graph')];
|
|
const res = await inject('POST', '/api/lab/sims/reorder', { order: reordered }, adminToken);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.count, 40);
|
|
|
|
const get2 = await inject('GET', '/api/lab/sims', null, adminToken);
|
|
assert.equal(get2.body.sims[0].id, 'angrybirds', 'angrybirds now first');
|
|
assert.equal(get2.body.sims[1].id, 'graph', 'graph now second');
|
|
});
|
|
|
|
it('reorder rejects unknown id and empty order', async () => {
|
|
const bad = await inject('POST', '/api/lab/sims/reorder', { order: ['ghost'] }, adminToken);
|
|
assert.equal(bad.status, 400, 'unknown id rejected');
|
|
const empty = await inject('POST', '/api/lab/sims/reorder', { order: [] }, adminToken);
|
|
assert.equal(empty.status, 400, 'empty order rejected');
|
|
});
|
|
|
|
it('reorder is admin-only (student → 403)', async () => {
|
|
const res = await inject('POST', '/api/lab/sims/reorder', { order: ['graph'] }, studentToken);
|
|
assert.equal(res.status, 403, `got ${res.status}`);
|
|
});
|
|
});
|