978448d99b
feat(quantik-game): фаза 3 — граф-уровни (движение по f(x)) + зоны Новый тип уровня: Квантик едет по кривой y=f(x), которую игрок собирает слайдерами коэффициентов, проходя сквозь зоны-препятствия. Движок (аддитивно): plot.runner → env-поля curve.runX/runY/runDone (f компилится 1 раз, питает И кривую, И бегунок-героя, без само-ссылки); type zone (forbidden/target/collect) → булево env-поле zone.hit. Грамматика выражений ЗАКРЫТА — никаких inzone()-предикатов, только именованные env-поля (модель t/tries из Ф0), без eval. Глава-созвездие functions из 5 уровней (луч/синус/парабола/модуль/экспонента), разблокировка 9/11/13/ 15/17 (цепочка проходима). validateSpec принимает zone+runner. Все 5 уровней независимо проверены на движке (2★ достижимы). npm test 253/8 baseline; custom-sims 26/26; lint:routes 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
241 lines
12 KiB
JavaScript
241 lines
12 KiB
JavaScript
'use strict';
|
||
/**
|
||
* Integration tests: /api/custom-sims — CRUD спек-симуляций (Фаза 3).
|
||
* Covers: auth, role-gating (POST teacher/admin), CRUD happy-path, ownership
|
||
* (чужой PUT/DELETE → 403), видимость (own draft / others published), serverная
|
||
* валидация спеки (кривая/огромная → 400), version-bump на update.
|
||
*/
|
||
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());
|
||
|
||
// Минимальная валидная спека (формат v1).
|
||
const VALID_SPEC = {
|
||
specVersion: 1,
|
||
meta: { title: 'Бросок' },
|
||
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
|
||
params: [{ name: 'v', label: 'Скорость', min: 0, max: 30, step: 0.5, value: 18, unit: 'м/с' }],
|
||
objects: [
|
||
{ id: 'p', type: 'point', x: 'v*t', y: '-4.9*t*t', r: 6, color: '#06D6E0' },
|
||
{ type: 'segment', x1: 0, y1: 0, x2: 'p.x', y2: 'p.y', color: '#fff', width: 2 },
|
||
],
|
||
physics: { enabled: true, gravity: { x: 0, y: -9.8 }, restitution: 0.9, dt: 1 / 240 },
|
||
};
|
||
|
||
describe('/api/custom-sims', () => {
|
||
let teacherToken, teacherId, otherTeacherToken, studentToken;
|
||
|
||
before(async () => {
|
||
const t = await getToken('teacher');
|
||
teacherToken = t.token; teacherId = t.userId;
|
||
otherTeacherToken = (await getToken('teacher')).token;
|
||
studentToken = (await getToken('student')).token;
|
||
});
|
||
|
||
it('GET /api/custom-sims requires auth (401 without token)', async () => {
|
||
const res = await inject('GET', '/api/custom-sims', null, null);
|
||
assert.equal(res.status, 401, `got ${res.status}`);
|
||
});
|
||
|
||
it('POST is role-gated: student → 403', async () => {
|
||
const res = await inject('POST', '/api/custom-sims', { spec: VALID_SPEC }, studentToken);
|
||
assert.equal(res.status, 403, `got ${res.status}`);
|
||
});
|
||
|
||
let simId;
|
||
it('teacher can create a sim (201) with valid spec', async () => {
|
||
const res = await inject('POST', '/api/custom-sims',
|
||
{ title: 'Бросок тела', subject: 'physics', grade: 9, cat: 'phys', spec: VALID_SPEC }, teacherToken);
|
||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||
assert.ok(Number.isFinite(res.body.id), 'returns numeric id');
|
||
simId = res.body.id;
|
||
});
|
||
|
||
it('GET /:id returns own sim with parsed spec + metadata + version 1', async () => {
|
||
const res = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||
assert.equal(res.status, 200, `got ${res.status}`);
|
||
const s = res.body.sim;
|
||
assert.equal(s.id, simId);
|
||
assert.equal(s.owner_id, teacherId);
|
||
assert.equal(s.title, 'Бросок тела');
|
||
assert.equal(s.subject, 'physics');
|
||
assert.equal(s.grade, 9);
|
||
assert.equal(s.cat, 'phys');
|
||
assert.equal(s.status, 'draft');
|
||
assert.equal(s.version, 1);
|
||
assert.equal(s.spec.specVersion, 1);
|
||
assert.equal(s.spec.objects.length, 2);
|
||
});
|
||
|
||
it('GET list returns own draft', async () => {
|
||
const res = await inject('GET', '/api/custom-sims', null, teacherToken);
|
||
assert.equal(res.status, 200, `got ${res.status}`);
|
||
assert.ok(Array.isArray(res.body.sims));
|
||
assert.ok(res.body.sims.find(s => s.id === simId), 'own draft present');
|
||
});
|
||
|
||
it("other teacher CANNOT see someone's draft in list, and GET draft → 403", async () => {
|
||
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||
assert.ok(!list.body.sims.find(s => s.id === simId), 'draft not in other user list');
|
||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||
assert.equal(get.status, 403, `got ${get.status}`);
|
||
});
|
||
|
||
it('owner PUT updates metadata + spec and bumps version', async () => {
|
||
const newSpec = { ...VALID_SPEC, meta: { title: 'Изменено' } };
|
||
const res = await inject('PUT', `/api/custom-sims/${simId}`,
|
||
{ title: 'Новое имя', status: 'published', spec: newSpec }, teacherToken);
|
||
assert.equal(res.status, 200, `got ${res.status}`);
|
||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||
assert.equal(get.body.sim.title, 'Новое имя');
|
||
assert.equal(get.body.sim.status, 'published');
|
||
assert.equal(get.body.sim.version, 2, 'version bumped');
|
||
assert.equal(get.body.sim.spec.meta.title, 'Изменено');
|
||
});
|
||
|
||
it('published sim is visible to other users (list + GET)', async () => {
|
||
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||
assert.ok(list.body.sims.find(s => s.id === simId), 'published in other user list');
|
||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, studentToken);
|
||
assert.equal(get.status, 200, 'student can read published');
|
||
assert.equal(get.body.sim.id, simId);
|
||
});
|
||
|
||
it("other teacher CANNOT PUT/DELETE someone else's sim (403)", async () => {
|
||
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'хак' }, otherTeacherToken);
|
||
assert.equal(put.status, 403, `PUT got ${put.status}`);
|
||
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||
assert.equal(del.status, 403, `DELETE got ${del.status}`);
|
||
});
|
||
|
||
it('admin can update/delete any sim', async () => {
|
||
const adminToken = (await getToken('admin')).token;
|
||
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'admin edit' }, adminToken);
|
||
assert.equal(put.status, 200, `admin PUT got ${put.status}`);
|
||
});
|
||
|
||
it('PUT/GET unknown id → 404', async () => {
|
||
assert.equal((await inject('GET', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||
assert.equal((await inject('PUT', '/api/custom-sims/999999', { title: 'x' }, teacherToken)).status, 404);
|
||
assert.equal((await inject('DELETE', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||
});
|
||
|
||
/* ── validateSpec: отклонение кривых/огромных спек (400) ── */
|
||
it('rejects missing spec (400)', async () => {
|
||
const res = await inject('POST', '/api/custom-sims', { title: 'нет спеки' }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects non-object spec (400)', async () => {
|
||
const res = await inject('POST', '/api/custom-sims', { spec: 'just a string' }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects wrong specVersion (400)', async () => {
|
||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, specVersion: 99 } }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects disallowed object type (400)', async () => {
|
||
const bad = { ...VALID_SPEC, objects: [{ type: 'eval_me', x: 1, y: 1 }] };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects too many objects (400)', async () => {
|
||
const objs = Array.from({ length: 201 }, () => ({ type: 'point', x: 1, y: 1 }));
|
||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, objects: objs } }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects too many params (400)', async () => {
|
||
const ps = Array.from({ length: 51 }, (_, i) => ({ name: 'p' + i, min: 0, max: 1, value: 0 }));
|
||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, params: ps } }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects over-long expression string (400)', async () => {
|
||
const bad = { ...VALID_SPEC, objects: [{ type: 'point', x: 'a+'.repeat(300) + '1', y: 0 }] };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects physics.restitution out of range (400)', async () => {
|
||
const bad = { ...VALID_SPEC, physics: { enabled: true, restitution: 5 } };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects body.mass <= 0 (400)', async () => {
|
||
const bad = { ...VALID_SPEC, objects: [{ type: 'circle', x: 0, y: 0, r: 1, body: { mass: 0 } }] };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects too many springs (400)', async () => {
|
||
const springs = Array.from({ length: 51 }, () => ({ a: [0, 0], b: [1, 1], k: 40, length: 1 }));
|
||
const res = await inject('POST', '/api/custom-sims',
|
||
{ spec: { ...VALID_SPEC, physics: { enabled: true, springs } } }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('rejects huge spec (>200KB) (400)', async () => {
|
||
const huge = { ...VALID_SPEC, meta: { title: 'x', desc: 'a'.repeat(300000) } };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: huge }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('sanitizes label/text fields (escapes angle brackets)', async () => {
|
||
const spec = {
|
||
...VALID_SPEC,
|
||
objects: [{ type: 'label', x: 0, y: 0, text: '<img src=x onerror=alert(1)>' }],
|
||
};
|
||
const create = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
|
||
assert.equal(create.status, 201, `got ${create.status}`);
|
||
const get = await inject('GET', `/api/custom-sims/${create.body.id}`, null, teacherToken);
|
||
const txt = get.body.sim.spec.objects[0].text;
|
||
assert.ok(!txt.includes('<img'), 'angle brackets escaped');
|
||
assert.ok(txt.includes('<img'), 'escaped form present');
|
||
});
|
||
|
||
it('accepts graph-level spec with zone + runner (Квантик Ф3)', async () => {
|
||
const spec = {
|
||
specVersion: 1,
|
||
meta: { title: 'Граф-уровень' },
|
||
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
|
||
params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }],
|
||
objects: [
|
||
{ id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } },
|
||
{ id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 },
|
||
{ type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' },
|
||
{ type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' },
|
||
],
|
||
goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] },
|
||
};
|
||
const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
|
||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken);
|
||
const objs = get.body.sim.spec.objects;
|
||
assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved');
|
||
assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved');
|
||
});
|
||
|
||
it('rejects unknown object type even with zone allowed (400)', async () => {
|
||
const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] };
|
||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||
assert.equal(res.status, 400, `got ${res.status}`);
|
||
});
|
||
|
||
it('owner can DELETE own sim (then 404)', async () => {
|
||
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
|
||
assert.equal(del.status, 200, `got ${del.status}`);
|
||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||
assert.equal(get.status, 404, 'gone after delete');
|
||
});
|
||
});
|