'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 colNames = new Set(cols.map(c => c.name)); // Keep ONLY keys that are real columns (drops fields absent in this schema — // robust to drift, e.g. topics may lack slug/subject_id on some branches). const row = {}; for (const k of Object.keys(provided)) if (colNames.has(k)) row[k] = provided[k]; // Fill any required (NOT NULL, no default) column the caller didn't provide. for (const c of cols) { if (c.pk) continue; if (c.name in row) continue; if (c.notnull && c.dflt_value === null) { 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 (schema-robust — fills NOT NULL cols). tbSlug = 'phys-test'; seedRow('textbooks', { slug: tbSlug, title: 'Физика тест', subject: 'physics', grade: 9, is_active: 1 }); const subjId = seedRow('subjects', { name: 'LinkTest Subj', slug: 'linktest-subj' }); topicId = seedRow('topics', { subject_id: subjId, name: 'Колебания тест', slug: 'kolebaniya-test' }); }); 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('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => { const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken); assert.equal(res.status, 200, `got ${res.status}`); assert.ok(res.body.byRef, 'byRef present'); assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`); assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug'); }); it('batch reverse lookup: bad kind → 400', async () => { const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken); assert.equal(res.status, 400); }); it('batch reverse lookup requires auth (401)', async () => { const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null); assert.equal(res.status, 401); }); 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); }); });