/
school25_dev
/
smena
Обзор
Документация
Войти
/
school25_dev
/
smena
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
tests/api.test.js
635 строк
35 KB
kalugin66
d
25 май 2026, 11:30
25 май 2026, 11:30
1970d5e
Код
Авторство
О чём код?
'use strict'; const http = require('http'); const path = require('path'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); const express = require('express'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const DATA_DIR = path.join(__dirname, '..', 'data'); const TEST_DB_PATH = path.join(DATA_DIR, 'test-camp.db'); const PORT = 3099; const TEST_JWT_SECRET = 'test-jwt-secret-key'; const DAYS = 15; let app, db, server; function parseTime(timeStr) { const str = String(timeStr); const [hours, minutes] = str.split(':').map(Number); return hours * 60 + minutes; } function formatTime(minutes) { const h = Math.floor(minutes / 60); const m = minutes % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } function dbRun(sql, params = []) { return new Promise((resolve, reject) => { db.run(sql, params, function(err) { if (err) reject(err); else resolve(this.lastID); }); }); } function dbAll(sql, params) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); } function initTestDb() { if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); db = new sqlite3.Database(TEST_DB_PATH); db.serialize(() => { db.run(`CREATE TABLE vozhaty (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); db.run(`CREATE TABLE performers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); db.run(`CREATE TABLE circles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, need_per_squad INTEGER DEFAULT 1, is_paid INTEGER DEFAULT 0, duration INTEGER DEFAULT 40, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); db.run(`CREATE TABLE circle_performers (circle_id INTEGER, performer_id INTEGER, PRIMARY KEY (circle_id, performer_id))`); db.run(`CREATE TABLE squads (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, points INTEGER DEFAULT 0, vozhaty_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); db.run(`CREATE TABLE schedule (id INTEGER PRIMARY KEY AUTOINCREMENT, squad_id INTEGER NOT NULL, circle_id INTEGER NOT NULL, day INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, UNIQUE(squad_id, day, start_time))`); db.run(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)`); db.run(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, full_name TEXT, email TEXT, role TEXT NOT NULL DEFAULT 'user', created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); const hash = bcrypt.hashSync('admin', 10); db.run(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, 'Администратор', 'admin')`, ['admin', hash]); [ ['min_free_circles', '4'], ['max_free_circles', '5'], ['lesson_duration', '40'], ['break_duration', '10'], ['day_start_time', '9:10'] ].forEach(([k, v]) => db.run(`INSERT INTO settings (key, value) VALUES (?, ?)`, [k, v])); [{ name: 'Василий Петрович', phone: '+7 900 111-11-01' }, { name: 'Ольга Николаевна', phone: '+7 900 111-11-02' }].forEach(v => db.run(`INSERT INTO vozhaty (name, phone) VALUES (?, ?)`, [v.name, v.phone])); ['Инструктор по спорту', 'Музыкальный руководитель', 'Хореограф'].forEach(n => db.run(`INSERT INTO performers (name) VALUES (?)`, [n])); [{ name: 'физкультура', need_per_squad: 4, is_paid: 0, duration: 40 }, { name: 'шахматы', need_per_squad: 2, is_paid: 0, duration: 40 }, { name: 'музыка', need_per_squad: 1, is_paid: 1, duration: 60 }, { name: 'танцы', need_per_squad: 1, is_paid: 1, duration: 45 }, { name: 'выезд на экскурсию', need_per_squad: 1, is_paid: 2, duration: 120 }].forEach(c => db.run(`INSERT INTO circles (name, need_per_squad, is_paid, duration) VALUES (?, ?, ?, ?)`, [c.name, c.need_per_squad, c.is_paid, c.duration])); [{ name: 'Отряд А', points: 0, vozhaty_id: 1 }, { name: 'Отряд Б', points: 0, vozhaty_id: 2 }].forEach(s => db.run(`INSERT INTO squads (name, points, vozhaty_id) VALUES (?, ?, ?)`, [s.name, s.points, s.vozhaty_id])); }); } function createServer() { app = express(); app.use(express.json()); function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; if (!authHeader) return res.status(401).json({ error: 'No token' }); const token = authHeader.split(' ')[1]; try { req.user = jwt.verify(token, TEST_JWT_SECRET); next(); } catch { res.status(403).json({ error: 'Invalid token' }); } } function requireAdmin(req, res, next) { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); next(); } app.post('/api/auth/login', (req, res) => { const { login, password } = req.body; db.get(`SELECT id, login, password_hash, full_name, email, role FROM users WHERE login = ?`, [login], (err, user) => { if (err || !user) return res.status(401).json({ error: 'Invalid credentials' }); if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, TEST_JWT_SECRET, { expiresIn: '24h' }); res.json({ token, user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role } }); }); }); app.get('/api/auth/me', authenticateToken, (req, res) => { db.get(`SELECT id, login, full_name, email, role FROM users WHERE id = ?`, [req.user.id], (err, row) => { if (err || !row) return res.status(404).json({ error: 'User not found' }); res.json(row); }); }); app.get('/api/settings', async (req, res) => { try { const rows = await dbAll(`SELECT * FROM settings`); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => { try { for (const s of req.body) { await dbRun(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, [s.key, s.value]); } res.json({ message: 'Settings saved' }); } catch { res.status(500).json({ error: 'Save error' }); } }); app.get('/api/vozhaty', async (req, res) => { try { const rows = await dbAll(`SELECT * FROM vozhaty ORDER BY name ASC`); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/vozhaty', authenticateToken, requireAdmin, async (req, res) => { try { const id = await dbRun(`INSERT INTO vozhaty (name, phone) VALUES (?, ?)`, [req.body.name, req.body.phone || null]); res.json({ id, name: req.body.name, phone: req.body.phone }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/vozhaty/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`UPDATE vozhaty SET name = ?, phone = ? WHERE id = ?`, [req.body.name, req.body.phone, req.params.id]); res.json({ id: parseInt(req.params.id), name: req.body.name, phone: req.body.phone }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/vozhaty/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM vozhaty WHERE id = ?`, [req.params.id]); res.json({ message: 'Deleted' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.get('/api/performers', async (req, res) => { try { const rows = await dbAll(`SELECT * FROM performers ORDER BY name ASC`); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/performers', authenticateToken, requireAdmin, async (req, res) => { try { const id = await dbRun(`INSERT INTO performers (name) VALUES (?)`, [req.body.name]); res.json({ id, name: req.body.name }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/performers/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`UPDATE performers SET name = ? WHERE id = ?`, [req.body.name, req.params.id]); res.json({ id: parseInt(req.params.id), name: req.body.name }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/performers/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM performers WHERE id = ?`, [req.params.id]); res.json({ message: 'Deleted' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.get('/api/circles', async (req, res) => { try { const rows = await dbAll(`SELECT c.*, GROUP_CONCAT(cp.performer_id) as performer_ids FROM circles c LEFT JOIN circle_performers cp ON c.id = cp.circle_id GROUP BY c.id ORDER BY c.name ASC`); rows.forEach(r => { r.performers = r.performer_ids ? r.performer_ids.split(',').map(Number) : []; delete r.performer_ids; }); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/circles', authenticateToken, requireAdmin, async (req, res) => { try { const id = await dbRun(`INSERT INTO circles (name, need_per_squad, is_paid, duration) VALUES (?, ?, ?, ?)`, [req.body.name, req.body.need_per_squad || 1, Number(req.body.is_paid) || 0, req.body.duration || 40]); res.json({ id, name: req.body.name, need_per_squad: req.body.need_per_squad || 1, is_paid: Number(req.body.is_paid) || 0, duration: req.body.duration || 40, performers: [] }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/circles/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`UPDATE circles SET name = ?, need_per_squad = ?, is_paid = ?, duration = ? WHERE id = ?`, [req.body.name, req.body.need_per_squad, Number(req.body.is_paid) || 0, req.body.duration, req.params.id]); res.json({ id: parseInt(req.params.id), name: req.body.name, need_per_squad: req.body.need_per_squad, is_paid: Number(req.body.is_paid) || 0, duration: req.body.duration }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/circles/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM circles WHERE id = ?`, [req.params.id]); res.json({ message: 'Deleted' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/circles/:id/performers', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM circle_performers WHERE circle_id = ?`, [req.params.id]); for (const pid of (req.body.performer_ids || [])) { await dbRun(`INSERT INTO circle_performers (circle_id, performer_id) VALUES (?, ?)`, [req.params.id, pid]); } res.json({ message: 'Performers updated' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.get('/api/squads', async (req, res) => { try { const rows = await dbAll(`SELECT s.*, v.name as vozhaty_name FROM squads s LEFT JOIN vozhaty v ON s.vozhaty_id = v.id ORDER BY s.name ASC`); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/squads', authenticateToken, requireAdmin, async (req, res) => { try { const id = await dbRun(`INSERT INTO squads (name, points, vozhaty_id) VALUES (?, ?, ?)`, [req.body.name, req.body.points || 0, req.body.vozhaty_id || null]); res.json({ id, name: req.body.name, points: req.body.points || 0, vozhaty_id: req.body.vozhaty_id || null }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/squads/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`UPDATE squads SET name = ?, points = ?, vozhaty_id = ? WHERE id = ?`, [req.body.name, req.body.points, req.body.vozhaty_id, req.params.id]); res.json({ id: parseInt(req.params.id), name: req.body.name, points: req.body.points, vozhaty_id: req.body.vozhaty_id }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/squads/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM squads WHERE id = ?`, [req.params.id]); res.json({ message: 'Deleted' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.get('/api/schedule', async (req, res) => { try { const rows = await dbAll(`SELECT sc.*, c.name as circle_name, c.is_paid FROM schedule sc JOIN circles c ON sc.circle_id = c.id ORDER BY sc.day, sc.start_time`); res.json(rows); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/schedule', authenticateToken, requireAdmin, async (req, res) => { try { const id = await dbRun(`INSERT INTO schedule (squad_id, circle_id, day, start_time, end_time) VALUES (?, ?, ?, ?, ?)`, [req.body.squad_id, req.body.circle_id, req.body.day, req.body.start_time, req.body.end_time]); res.json({ id, squad_id: req.body.squad_id, circle_id: req.body.circle_id, day: req.body.day, start_time: req.body.start_time, end_time: req.body.end_time }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.put('/api/schedule/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`UPDATE schedule SET squad_id = ?, circle_id = ?, day = ?, start_time = ?, end_time = ? WHERE id = ?`, [req.body.squad_id, req.body.circle_id, req.body.day, req.body.start_time, req.body.end_time, req.params.id]); res.json({ id: parseInt(req.params.id), squad_id: req.body.squad_id, circle_id: req.body.circle_id, day: req.body.day, start_time: req.body.start_time, end_time: req.body.end_time }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/schedule/clear', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM schedule`); res.json({ message: 'Schedule cleared' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.delete('/api/schedule/:id', authenticateToken, requireAdmin, async (req, res) => { try { await dbRun(`DELETE FROM schedule WHERE id = ?`, [req.params.id]); res.json({ message: 'Deleted' }); } catch { res.status(500).json({ error: 'Database error' }); } }); app.post('/api/schedule/generate', authenticateToken, requireAdmin, async (req, res) => { try { const settingsRows = await dbAll(`SELECT * FROM settings`); const settings = {}; settingsRows.forEach(r => { if (r.key === 'day_start_time') settings[r.key] = r.value; else settings[r.key] = parseInt(r.value); }); const minFree = settings.min_free_circles || 4; const maxFree = settings.max_free_circles || 5; const lessonDuration = settings.lesson_duration || 40; const breakDuration = settings.break_duration || 10; const dayStartTime = parseTime(settings.day_start_time || '9:10'); const freeCircles = await dbAll(`SELECT * FROM circles WHERE is_paid = 0 ORDER BY name ASC`); const squads = await dbAll(`SELECT * FROM squads ORDER BY name ASC`); const existingSchedule = await dbAll(`SELECT sc.*, c.is_paid FROM schedule sc JOIN circles c ON sc.circle_id = c.id`); await dbRun(`DELETE FROM schedule WHERE id IN (SELECT sc.id FROM schedule sc JOIN circles c ON sc.circle_id = c.id WHERE c.is_paid = 0)`); const insertBatch = []; for (const squad of squads) { for (let day = 1; day <= DAYS; day++) { const paidSlots = existingSchedule.filter(s => s.squad_id === squad.id && s.day === day && s.is_paid >= 1); const occupiedRanges = paidSlots.map(ps => ({ start: parseTime(ps.start_time), end: parseTime(ps.end_time) })); let allActivities = []; freeCircles.forEach(c => { for (let i = 0; i < c.need_per_squad; i++) { allActivities.push({ id: c.id, name: c.name }); } }); shuffleArray(allActivities); const placedCircles = new Set(); for (let i = 0; i < allActivities.length; i++) { const circleEntry = allActivities[i]; if (placedCircles.has(circleEntry.id)) continue; let placed = false; for (let attempt = 0; attempt < 5; attempt++) { const slotTime = dayStartTime + attempt * (lessonDuration + breakDuration); const slotEnd = slotTime + lessonDuration; const hasConflict = occupiedRanges.some(r => slotTime < r.end && r.start < slotEnd); if (!hasConflict) { insertBatch.push([squad.id, circleEntry.id, day, formatTime(slotTime), formatTime(slotEnd)]); placedCircles.add(circleEntry.id); occupiedRanges.push({ start: slotTime, end: slotEnd }); placed = true; break; } } if (!placed) break; } } } for (const params of insertBatch) { await dbRun(`INSERT INTO schedule (squad_id, circle_id, day, start_time, end_time) VALUES (?, ?, ?, ?, ?)`, params); } res.json({ message: 'Free circles generated', count: insertBatch.length }); } catch (err) { console.error('Generate error:', err); res.status(500).json({ error: 'Generate error' }); } }); app.get('/api/schedule/view', async (req, res) => { try { const squads = await dbAll(`SELECT s.*, v.name as vozhaty_name FROM squads s LEFT JOIN vozhaty v ON s.vozhaty_id = v.id ORDER BY s.name ASC`); const circles = await dbAll(`SELECT * FROM circles ORDER BY name ASC`); const schedule = await dbAll(`SELECT sc.*, c.name as circle_name, c.is_paid, c.need_per_squad, c.duration FROM schedule sc JOIN circles c ON sc.circle_id = c.id ORDER BY sc.day, sc.start_time`); const settingsRows = await dbAll(`SELECT * FROM settings`); const settings = {}; settingsRows.forEach(r => settings[r.key] = r.value); res.json({ squads, circles, schedule, settings, days: DAYS }); } catch (err) { res.status(500).json({ error: 'Database error' }); } }); return app; } function api(action, url, body, token) { return new Promise((resolve, reject) => { const opts = { method: action, hostname: 'localhost', port: PORT, path: url, headers: { 'Content-Type': 'application/json' } }; if (token) opts.headers['Authorization'] = 'Bearer ' + token; const req = http.request(opts, res => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } catch { resolve({ status: res.statusCode, body: data }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } beforeAll(async () => { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR); initTestDb(); app = createServer(); await new Promise(resolve => { server = app.listen(PORT, resolve); }); }); afterAll(async () => { await new Promise(resolve => server.close(resolve)); await new Promise(resolve => db.close(resolve)); }); let adminToken; describe('Auth', () => { test('login with valid credentials', async () => { const res = await api('POST', '/api/auth/login', { login: 'admin', password: 'admin' }); expect(res.status).toBe(200); expect(res.body.token).toBeDefined(); expect(res.body.user.role).toBe('admin'); adminToken = res.body.token; }); test('login with invalid credentials', async () => { const res = await api('POST', '/api/auth/login', { login: 'admin', password: 'wrong' }); expect(res.status).toBe(401); }); test('me endpoint with valid token', async () => { const res = await api('GET', '/api/auth/me', null, adminToken); expect(res.status).toBe(200); expect(res.body.login).toBe('admin'); }); test('me endpoint without token', async () => { const res = await api('GET', '/api/auth/me', null, null); expect(res.status).toBe(401); }); }); describe('Settings', () => { test('get settings', async () => { const res = await api('GET', '/api/settings'); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); const dayStart = res.body.find(s => s.key === 'day_start_time'); expect(dayStart.value).toBe('9:10'); }); test('update settings (admin)', async () => { const res = await api('PUT', '/api/settings', [{ key: 'day_start_time', value: '10:00' }], adminToken); expect(res.status).toBe(200); const check = await api('GET', '/api/settings'); expect(check.body.find(s => s.key === 'day_start_time').value).toBe('10:00'); await api('PUT', '/api/settings', [{ key: 'day_start_time', value: '9:10' }], adminToken); }); test('update settings without auth', async () => { const res = await api('PUT', '/api/settings', [{ key: 'lesson_duration', value: '45' }]); expect(res.status).toBe(401); }); }); describe('Vozhaty CRUD', () => { test('list vozhaty', async () => { const res = await api('GET', '/api/vozhaty'); expect(res.status).toBe(200); expect(res.body.length).toBe(2); }); test('create vozhaty', async () => { const res = await api('POST', '/api/vozhaty', { name: 'Тестовый Вожатый', phone: '+7 900 222-22-22' }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Тестовый Вожатый'); }); test('update vozhaty', async () => { const create = await api('POST', '/api/vozhaty', { name: 'Для обновления' }, adminToken); const res = await api('PUT', '/api/vozhaty/' + create.body.id, { name: 'Обновлённый', phone: '+7 900 333-33-33' }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Обновлённый'); }); test('delete vozhaty', async () => { const create = await api('POST', '/api/vozhaty', { name: 'Удаляемый' }, adminToken); const res = await api('DELETE', '/api/vozhaty/' + create.body.id, null, adminToken); expect(res.status).toBe(200); }); }); describe('Performers CRUD', () => { test('list performers', async () => { const res = await api('GET', '/api/performers'); expect(res.status).toBe(200); expect(res.body.length).toBe(3); }); test('create performer', async () => { const res = await api('POST', '/api/performers', { name: 'Тестовый Артист' }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Тестовый Артист'); }); test('update performer', async () => { const create = await api('POST', '/api/performers', { name: 'Старое имя' }, adminToken); const res = await api('PUT', '/api/performers/' + create.body.id, { name: 'Новое имя' }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Новое имя'); }); test('delete performer', async () => { const create = await api('POST', '/api/performers', { name: 'Удаляемый артист' }, adminToken); const res = await api('DELETE', '/api/performers/' + create.body.id, null, adminToken); expect(res.status).toBe(200); }); }); describe('Circles CRUD', () => { test('list circles', async () => { const res = await api('GET', '/api/circles'); expect(res.status).toBe(200); expect(res.body.length).toBe(5); const paid = res.body.find(c => c.name === 'музыка'); expect(paid.is_paid).toBe(1); const free = res.body.find(c => c.name === 'физкультура'); expect(free.is_paid).toBe(0); const fieldTrip = res.body.find(c => c.name === 'выезд на экскурсию'); expect(fieldTrip.is_paid).toBe(2); }); test('create circle', async () => { const res = await api('POST', '/api/circles', { name: 'Новый кружок', need_per_squad: 3, is_paid: false, duration: 50 }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Новый кружок'); expect(res.body.is_paid).toBe(0); }); test('update circle', async () => { const create = await api('POST', '/api/circles', { name: 'Кружок для обновления', need_per_squad: 1, is_paid: false, duration: 40 }, adminToken); const res = await api('PUT', '/api/circles/' + create.body.id, { name: 'Обновлённый кружок', need_per_squad: 2, is_paid: true, duration: 60 }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Обновлённый кружок'); expect(res.body.is_paid).toBe(1); }); test('delete circle', async () => { const create = await api('POST', '/api/circles', { name: 'Удаляемый кружок', need_per_squad: 1, is_paid: false, duration: 40 }, adminToken); const res = await api('DELETE', '/api/circles/' + create.body.id, null, adminToken); expect(res.status).toBe(200); }); test('assign performers to circle', async () => { const circleRes = await api('POST', '/api/circles', { name: 'Кружок с артистами', need_per_squad: 1, is_paid: true, duration: 45 }, adminToken); const res = await api('PUT', '/api/circles/' + circleRes.body.id + '/performers', { performer_ids: [1, 2] }, adminToken); expect(res.status).toBe(200); }); }); describe('Squads CRUD', () => { afterEach(async () => { const res = await api('GET', '/api/squads'); for (const s of res.body) { if (s.id > 2) await api('DELETE', '/api/squads/' + s.id, null, adminToken); } }); test('list squads', async () => { const res = await api('GET', '/api/squads'); expect(res.status).toBe(200); expect(res.body.length).toBe(2); }); test('create squad', async () => { const res = await api('POST', '/api/squads', { name: 'Тестовый Отряд', points: 10, vozhaty_id: 1 }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Тестовый Отряд'); }); test('update squad', async () => { const create = await api('POST', '/api/squads', { name: 'Отряд для обновления', points: 0 }, adminToken); const res = await api('PUT', '/api/squads/' + create.body.id, { name: 'Обновлённый отряд', points: 5, vozhaty_id: 2 }, adminToken); expect(res.status).toBe(200); expect(res.body.name).toBe('Обновлённый отряд'); }); test('delete squad', async () => { const create = await api('POST', '/api/squads', { name: 'Удаляемый отряд' }, adminToken); const res = await api('DELETE', '/api/squads/' + create.body.id, null, adminToken); expect(res.status).toBe(200); }); }); describe('Schedule CRUD', () => { beforeEach(async () => { await api('DELETE', '/api/schedule/clear', null, adminToken); }); test('list schedule', async () => { const res = await api('GET', '/api/schedule'); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); test('create schedule entry', async () => { const res = await api('POST', '/api/schedule', { squad_id: 1, circle_id: 3, day: 1, start_time: '12:00', end_time: '12:45' }, adminToken); expect(res.status).toBe(200); expect(res.body.start_time).toBe('12:00'); }); test('update schedule entry', async () => { const create = await api('POST', '/api/schedule', { squad_id: 1, circle_id: 3, day: 1, start_time: '13:00', end_time: '13:45' }, adminToken); const res = await api('PUT', '/api/schedule/' + create.body.id, { squad_id: 2, circle_id: 3, day: 1, start_time: '14:00', end_time: '14:45' }, adminToken); expect(res.status).toBe(200); expect(res.body.start_time).toBe('14:00'); }); test('delete schedule entry', async () => { const create = await api('POST', '/api/schedule', { squad_id: 1, circle_id: 3, day: 2, start_time: '15:00', end_time: '15:45' }, adminToken); const res = await api('DELETE', '/api/schedule/' + create.body.id, null, adminToken); expect(res.status).toBe(200); }); test('clear schedule', async () => { await api('POST', '/api/schedule', { squad_id: 1, circle_id: 3, day: 3, start_time: '16:00', end_time: '16:45' }, adminToken); const res = await api('DELETE', '/api/schedule/clear', null, adminToken); expect(res.status).toBe(200); const schedule = await api('GET', '/api/schedule'); expect(schedule.body.length).toBe(0); }); }); describe('Schedule Generate (Auto-assignment)', () => { beforeEach(async () => { await api('DELETE', '/api/schedule/clear', null, adminToken); }); afterEach(async () => { await api('DELETE', '/api/schedule/clear', null, adminToken); }); test('generates entries for all squads and all days', async () => { const res = await api('POST', '/api/schedule/generate', {}, adminToken); expect(res.status).toBe(200); expect(res.body.count).toBeGreaterThan(0); const scheduleRes = await api('GET', '/api/schedule'); const schedule = scheduleRes.body; const squads = new Set(schedule.map(s => s.squad_id)); const days = new Set(schedule.map(s => s.day)); expect(squads.size).toBe(2); expect(days.size).toBe(DAYS); }); test('time format is HH:mm (padded)', async () => { await api('POST', '/api/schedule/generate', {}, adminToken); const res = await api('GET', '/api/schedule'); const badTimes = res.body.filter(s => !/^\d{2}:\d{2}$/.test(s.start_time)); expect(badTimes.length).toBe(0); }); test('no circle repeats on same day for same squad', async () => { await api('POST', '/api/schedule/generate', {}, adminToken); const res = await api('GET', '/api/schedule'); const byDaySquad = {}; res.body.forEach(s => { const key = `${s.squad_id}-${s.day}`; if (!byDaySquad[key]) byDaySquad[key] = []; byDaySquad[key].push(s.circle_id); }); let hasConflict = false; for (const key in byDaySquad) { if (new Set(byDaySquad[key]).size !== byDaySquad[key].length) { hasConflict = true; break; } } expect(hasConflict).toBe(false); }); test('no time overlap within free circles', async () => { await api('POST', '/api/schedule/generate', {}, adminToken); const res = await api('GET', '/api/schedule'); const byDaySquad = {}; res.body.forEach(s => { const key = `${s.squad_id}-${s.day}`; if (!byDaySquad[key]) byDaySquad[key] = []; byDaySquad[key].push({ start: parseTime(s.start_time), end: parseTime(s.end_time) }); }); let hasOverlap = false; outer: for (const key in byDaySquad) { const slots = byDaySquad[key]; for (let i = 0; i < slots.length; i++) { for (let j = i + 1; j < slots.length; j++) { if (slots[i].start < slots[j].end && slots[j].start < slots[i].end) { hasOverlap = true; break outer; } } } } expect(hasOverlap).toBe(false); }); test('paid and field-trip circles are preserved after generate', async () => { await api('POST', '/api/schedule', { squad_id: 1, circle_id: 3, day: 1, start_time: '11:00', end_time: '12:00' }, adminToken); await api('POST', '/api/schedule', { squad_id: 1, circle_id: 4, day: 3, start_time: '15:00', end_time: '15:45' }, adminToken); await api('POST', '/api/schedule', { squad_id: 2, circle_id: 5, day: 5, start_time: '10:00', end_time: '12:00' }, adminToken); await api('POST', '/api/schedule/generate', {}, adminToken); const res = await api('GET', '/api/schedule'); const paidEntries = res.body.filter(s => s.is_paid === 1); expect(paidEntries.length).toBe(2); const fieldTripEntries = res.body.filter(s => s.is_paid === 2); expect(fieldTripEntries.length).toBe(1); }); test('free circle duration equals lesson_duration setting', async () => { await api('PUT', '/api/settings', [{ key: 'lesson_duration', value: '50' }], adminToken); await api('POST', '/api/schedule/generate', {}, adminToken); const res = await api('GET', '/api/schedule'); const freeEntries = res.body.filter(s => s.is_paid === 0); for (const entry of freeEntries) { const duration = parseTime(entry.end_time) - parseTime(entry.start_time); expect(duration).toBe(50); } await api('PUT', '/api/settings', [{ key: 'lesson_duration', value: '40' }], adminToken); }); test('generate endpoint requires auth', async () => { const res = await api('POST', '/api/schedule/generate', {}); expect(res.status).toBe(401); }); }); describe('Schedule View', () => { test('returns squads, circles, schedule and settings', async () => { const res = await api('GET', '/api/schedule/view'); expect(res.status).toBe(200); expect(res.body.squads).toBeDefined(); expect(res.body.circles).toBeDefined(); expect(res.body.schedule).toBeDefined(); expect(res.body.settings).toBeDefined(); expect(res.body.days).toBe(DAYS); }); });