LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
const { describe, it, after } = require('node:test');
const assert = require('node:assert/strict');
const { db, inject, getToken, cleanup } = require('./setup');
after(() => cleanup());
describe('Auth', () => {
it('registers a new user', async () => {
const res = await inject('POST', '/api/auth/register', {
email: 'auth1@test.com', password: 'pass123', name: 'Auth User'
});
assert.equal(res.status, 201);
assert.ok(res.body.token);
assert.equal(res.body.user.email, 'auth1@test.com');
});
it('rejects duplicate email', async () => {
const res = await inject('POST', '/api/auth/register', {
email: 'auth1@test.com', password: 'pass123', name: 'Dupe'
});
assert.equal(res.status, 409);
});
it('validates required fields', async () => {
const res = await inject('POST', '/api/auth/register', { email: 'x@y.z' });
assert.equal(res.status, 400);
});
it('validates email format', async () => {
const res = await inject('POST', '/api/auth/register', {
email: 'not-an-email', password: 'pass123', name: 'Bad'
});
assert.equal(res.status, 400);
});
it('validates password min length', async () => {
const res = await inject('POST', '/api/auth/register', {
email: 'short@test.com', password: '12345', name: 'Short'
});
assert.equal(res.status, 400);
});
it('logs in with correct credentials', async () => {
const res = await inject('POST', '/api/auth/login', {
email: 'auth1@test.com', password: 'pass123'
});
assert.equal(res.status, 200);
assert.ok(res.body.token);
});
it('rejects wrong password', async () => {
const res = await inject('POST', '/api/auth/login', {
email: 'auth1@test.com', password: 'wrong'
});
assert.equal(res.status, 401);
});
it('GET /me returns user with valid token', async () => {
const { token } = await getToken();
const res = await inject('GET', '/api/auth/me', null, token);
assert.equal(res.status, 200);
assert.ok(res.body.id);
});
it('rejects request without token', async () => {
const res = await inject('GET', '/api/auth/me');
assert.equal(res.status, 401);
});
it('invalidates token after password change', async () => {
// Register
const reg = await inject('POST', '/api/auth/register', {
email: 'pwchange@test.com', password: 'old_pass123', name: 'PW Test'
});
const oldToken = reg.body.token;
// Change password
const upd = await inject('PATCH', '/api/auth/profile', {
currentPassword: 'old_pass123', newPassword: 'new_pass123'
}, oldToken);
assert.equal(upd.status, 200);
const newToken = upd.body.token;
// Old token should be rejected
const res1 = await inject('GET', '/api/auth/me', null, oldToken);
assert.equal(res1.status, 401);
// New token should work
const res2 = await inject('GET', '/api/auth/me', null, newToken);
assert.equal(res2.status, 200);
});
});
+106
View File
@@ -0,0 +1,106 @@
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { db, inject, getToken, cleanup } = require('./setup');
after(() => cleanup());
describe('Sessions', () => {
let token, userId;
before(async () => {
const u = await getToken('student');
token = u.token; userId = u.userId;
// Ensure subject + questions exist
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get('chem');
if (!subj) {
db.prepare("INSERT INTO subjects (slug, name, icon) VALUES ('chem', 'Химия', 'atom')").run();
}
const subjId = db.prepare('SELECT id FROM subjects WHERE slug = ?').get('chem').id;
// Create some questions
const ins = db.prepare('INSERT INTO questions (subject_id, text, difficulty) VALUES (?, ?, 1)');
for (let i = 0; i < 5; i++) {
const qId = ins.run(subjId, `Test question ${i + 1}`).lastInsertRowid;
// Add options
db.prepare('INSERT INTO options (question_id, text, is_correct) VALUES (?, ?, ?)').run(qId, 'Wrong', 0);
db.prepare('INSERT INTO options (question_id, text, is_correct) VALUES (?, ?, ?)').run(qId, 'Correct', 1);
}
});
it('validates subject_slug required', async () => {
const res = await inject('POST', '/api/sessions', { mode: 'exam' }, token);
assert.equal(res.status, 400);
});
it('validates mode enum', async () => {
const res = await inject('POST', '/api/sessions', {
subject_slug: 'chem', mode: 'invalid'
}, token);
assert.equal(res.status, 400);
});
it('validates count range', async () => {
const res = await inject('POST', '/api/sessions', {
subject_slug: 'chem', count: 999
}, token);
assert.equal(res.status, 400);
});
it('starts a session successfully', async () => {
const res = await inject('POST', '/api/sessions', {
subject_slug: 'chem', count: 3, mode: 'exam'
}, token);
assert.equal(res.status, 201);
assert.ok(res.body.session_id);
assert.equal(res.body.mode, 'exam');
assert.ok(res.body.questions.length > 0);
});
it('submits an answer', async () => {
// Start fresh session
const s = await inject('POST', '/api/sessions', {
subject_slug: 'chem', count: 2, mode: 'practice'
}, token);
const sessionId = s.body.session_id;
const q = s.body.questions[0];
const res = await inject('POST', `/api/sessions/${sessionId}/answer`, {
question_id: q.id, option_id: q.options[0].id
}, token);
assert.equal(res.status, 200);
assert.ok('is_correct' in res.body);
});
it('validates answer question_id required', async () => {
const s = await inject('POST', '/api/sessions', {
subject_slug: 'chem', count: 2
}, token);
const sessionId = s.body.session_id;
const res = await inject('POST', `/api/sessions/${sessionId}/answer`, {}, token);
assert.equal(res.status, 400);
});
it('finishes a session', async () => {
const s = await inject('POST', '/api/sessions', {
subject_slug: 'chem', count: 1
}, token);
const sessionId = s.body.session_id;
const res = await inject('POST', `/api/sessions/${sessionId}/finish`, {}, token);
assert.equal(res.status, 200);
assert.ok('score' in res.body);
});
it('returns session history', async () => {
const res = await inject('GET', '/api/sessions/history', null, token);
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.rows));
});
it('rejects session start without auth', async () => {
const res = await inject('POST', '/api/sessions', { subject_slug: 'chem' });
assert.equal(res.status, 401);
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Test setup — creates a temp SQLite DB, runs migrations, boots Express app.
* Usage: const { app, db, getToken } = require('./setup');
*/
const path = require('path');
const os = require('os');
const fs = require('fs');
// Unique temp DB per test run
const tmpDb = path.join(os.tmpdir(), `ls_test_${Date.now()}_${Math.random().toString(36).slice(2)}.db`);
process.env.DB_PATH = tmpDb;
process.env.JWT_SECRET = 'test_secret_that_is_at_least_32_characters_long!!';
process.env.JWT_EXPIRES_IN = '1h';
process.env.NODE_ENV = 'test';
// Now require app modules (they will use the temp DB)
const db = require('../src/db/db');
require('../src/db/migrate');
// Seed permissions
const { seedDefaults } = require('../src/controllers/permissionsController');
seedDefaults();
// Seed achievements
const { seedAchievements } = require('../src/controllers/gamificationController');
seedAchievements();
// Build Express app (without listen)
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use('/api/auth', require('../src/routes/auth'));
app.use('/api/sessions', require('../src/routes/sessions'));
app.use('/api/shop', require('../src/routes/shop'));
app.use('/api/classes', require('../src/routes/classes'));
app.use('/api/gamification', require('../src/routes/gamification'));
app.use('/api/admin', require('../src/routes/admin'));
app.use('/api/subjects', require('../src/routes/subjects'));
app.use('/api/questions', require('../src/routes/questions'));
// Error handler
app.use((err, _req, res, _next) => {
res.status(err.status || 500).json({ error: err.message || 'Server error' });
});
/* ── Helper: register + get token ─────────────────────────────────────── */
let _userCounter = 0;
async function getToken(role = 'student', nameOverride) {
const n = ++_userCounter;
const email = `test${n}@example.com`;
const name = nameOverride || `Test User ${n}`;
const password = 'password123';
// Register via API
const res = await inject('POST', '/api/auth/register', { email, password, name });
const token = res.body.token;
const userId = res.body.user.id;
// If role is not student, set it directly in DB
if (role !== 'student') {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, userId);
}
return { token, userId, email, name };
}
/* ── HTTP injection (no real TCP) ─────────────────────────────────────── */
const http = require('http');
let _server;
function ensureServer() {
if (!_server) {
_server = http.createServer(app);
_server.listen(0); // random port
}
return _server;
}
async function inject(method, path, body, token) {
const server = ensureServer();
const port = server.address().port;
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (data) headers['Content-Length'] = Buffer.byteLength(data);
const req = http.request({ hostname: '127.0.0.1', port, path, method, headers }, (res) => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
/* ── Cleanup ──────────────────────────────────────────────────────────── */
function cleanup() {
if (_server) _server.close();
try { fs.unlinkSync(tmpDb); } catch {}
}
module.exports = { app, db, getToken, inject, cleanup };
+86
View File
@@ -0,0 +1,86 @@
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { db, inject, getToken, cleanup } = require('./setup');
after(() => cleanup());
describe('Shop', () => {
let studentToken, studentId, adminToken, itemId;
before(async () => {
const s = await getToken('student');
studentToken = s.token; studentId = s.userId;
const a = await getToken('admin');
adminToken = a.token;
// Give student some coins
db.prepare('UPDATE users SET coins = 500 WHERE id = ?').run(studentId);
// Create a shop item via admin
const res = await inject('POST', '/api/shop/admin/items', {
name: 'Test Frame', type: 'frame', price: 100, category: 'cosmetic',
data: '{"css":"box-shadow:0 0 5px red"}', icon: 'star'
}, adminToken);
assert.equal(res.status, 200);
itemId = res.body.id;
});
it('lists items for student', async () => {
const res = await inject('GET', '/api/shop/items', null, studentToken);
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.items));
assert.equal(res.body.coins, 500);
});
it('purchases item atomically', async () => {
const res = await inject('POST', `/api/shop/items/${itemId}/purchase`, {}, studentToken);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.coins, 400); // 500 - 100
});
it('rejects duplicate purchase', async () => {
const res = await inject('POST', `/api/shop/items/${itemId}/purchase`, {}, studentToken);
assert.equal(res.status, 400);
assert.ok(res.body.error.includes('уже купили'));
});
it('rejects purchase with insufficient coins', async () => {
// Create expensive item
const cr = await inject('POST', '/api/shop/admin/items', {
name: 'Expensive', type: 'title', price: 99999
}, adminToken);
const expId = cr.body.id;
const res = await inject('POST', `/api/shop/items/${expId}/purchase`, {}, studentToken);
assert.equal(res.status, 400);
assert.ok(res.body.error.includes('монет'));
});
it('activates purchased item', async () => {
const res = await inject('POST', '/api/shop/activate', { itemId }, studentToken);
assert.equal(res.status, 200);
assert.equal(res.body.type, 'frame');
});
it('validates activate type field', async () => {
const res = await inject('POST', '/api/shop/activate', { type: 'invalid' }, studentToken);
assert.equal(res.status, 400);
});
it('validates admin create item fields', async () => {
const res = await inject('POST', '/api/shop/admin/items', { name: 'X' }, adminToken);
assert.equal(res.status, 400); // missing type, price
});
it('validates admin award-coins fields', async () => {
const res = await inject('POST', '/api/shop/admin/award-coins', { userId: 'abc' }, adminToken);
assert.equal(res.status, 400);
});
it('returns coin balance', async () => {
const res = await inject('GET', '/api/shop/coins', null, studentToken);
assert.equal(res.status, 200);
assert.equal(typeof res.body.coins, 'number');
});
});