Files
WhatIDo/api.smoketest.js
T
dev c994edbed7 feat(lessons): тема занятия в отчёте о занятии
В модалку #lessonModal добавлено текстовое поле «Тема занятия» (#lessonTopic)
между датой/временем и текстом отчёта. Поле необязательное, лимит 300 символов.

Протянуто по всему срезу:
- колонка lesson_reports.topic в db/init.sql, db/migration.sql и
  ensureLessonReportsTable() — миграция идемпотентная, ALTER IF NOT EXISTS
- POST /api/lesson-reports принимает topic, PUT обновляет; в GET-списке
  колонка добавлена в явный SELECT (там не SELECT *, без правки не пришла бы)
- тема в normalizeRestoreData, чтобы бэкап/restore её не теряли
- тема уходит в ИИ-контекст воркера строкой «Тема занятия: ...»
- вывод в списке отчётов (lessons.js + .lesson-topic в admin.css)

В PUT пустая строка очищает тему, а отсутствие поля в теле запроса её не
трогает: CASE WHEN $6::boolean THEN $3::text ELSE topic END, а не COALESCE —
при COALESCE пустая тема затиралась бы старым значением и поле нельзя было бы
очистить.

Версии не трогаем: lesson_report_versions хранит только text.

Тесты: +5 проверок (создание, список, редактирование, очистка, >300 символов),
api.smoketest.js — 76 PASS / 0 FAIL.
2026-10-04 11:56:55 +03:00

320 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
function loadEnv() {
const file = path.join(__dirname, '.env');
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
const BASE = process.env.BASE || 'http://localhost:3003';
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
const headers = {};
if (token) headers['X-Auth-Token'] = token;
if (body) headers['Content-Type'] = 'application/json';
Object.assign(headers, extra || {});
const res = await fetch(BASE + pathname, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = text;
try { data = JSON.parse(text); } catch (e) {}
return { status: res.status, data, headers: res.headers };
}
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) process.exitCode = 1;
return cond;
}
async function main() {
loadEnv();
const user = process.env.ADMIN_USERNAME || 'admin';
const pass = process.env.ADMIN_PASSWORD;
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
console.log(JSON.stringify(login.data).slice(0, 300));
return;
}
const token = login.data.token;
const me1 = await api('/api/auth/me', { token });
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
// где он задаётся через login.
const PROTECTED = '/api/auth/me';
const ADMIN_ONLY = '/api/users';
const noAuth = await api(PROTECTED);
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
const garbage = await api(PROTECTED, { token: 'deadbeef' });
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
const passAsToken = await api(PROTECTED, { token: pass });
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
const positive = await api(ADMIN_ONLY, { token });
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
const publicNoAuth = await api('/api/groups');
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
const groups = await api('/api/groups', { token });
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
const groups2 = await api('/api/groups', { token });
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
const pub = await api('/api/public-settings');
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
const students = await api('/api/students', { token });
ok('students', students.status === 200, students.status);
const stats = await api('/api/stats', { token });
ok('stats', stats.status === 200, stats.status);
const dash = await api('/api/dashboard', { token });
ok('dashboard', dash.status === 200, dash.status);
const info = await api('/api/system-info', { token });
ok('system-info', info.status === 200, info.status);
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
const stack = info.data && info.data.stack;
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
const limits = await api('/api/groups');
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
limit: limits.headers.get('ratelimit-limit'),
remaining: limits.headers.get('ratelimit-remaining'),
reset: limits.headers.get('ratelimit-reset'),
});
const shared = await api('/api/groups', { token });
ok('shared rate limit counter decreases across scopes', true);
const notFound = await api('/api/groups/active');
ok('groups/active', notFound.status === 200, notFound.status);
const marker = 'RedisTest' + Date.now();
const before = await api('/api/public-settings');
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
ok('PUT /api/settings', put.status === 200, put.status);
const after = await api('/api/public-settings');
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
before: before.data.system_name,
after: after.data.system_name,
});
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
const restoredCheck = await api('/api/public-settings');
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
const tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } });
ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status);
const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } });
ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status);
const pubTz = await api('/api/public-settings');
ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), {
timezone: pubTz.data.timezone,
time_format: pubTz.data.time_format,
});
const noFilter = await api('/api/files?limit=5', { token });
ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status);
const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status);
const entriesNoFilter = await api('/api/entries?limit=5', { token });
ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status);
const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status);
const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status);
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
const bypass = await api('/api/groups', { token, headers: {} });
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
ok('sse stream opens', events.status === 200);
if (events.status === 200) {
const reader = events.body.getReader();
const first = await reader.read();
const text = new TextDecoder().decode(first.value || new Uint8Array());
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
reader.cancel().catch(() => {});
}
const notifyNoAuth = await api('/api/notifications');
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
const notifyList = await api('/api/notifications?limit=5', { token });
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
const notifyMeta = await api('/api/notifications/meta', { token });
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
const notifyUnread = await api('/api/notifications?unread=1', { token });
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
if (notifyStream.status === 200) {
const reader = notifyStream.body.getReader();
const first = await reader.read();
const text = new TextDecoder().decode(first.value || new Uint8Array());
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
reader.cancel().catch(() => {});
}
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
const photoStatus = await api('/api/photo-jobs/status', { token });
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
photo_ai_enabled: pub.data.photo_ai_enabled,
ai_configured: photoStatus.data.ai_configured,
});
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
const photoSvc = photoStatus.data.service;
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
service: photoSvc,
ai_configured: photoStatus.data.ai_configured,
});
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
ai_configured: photoStatus.data.ai_configured,
reachable: photoSvc.reachable,
});
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
worker_ai_url: workerCfg && workerCfg.ai_url,
ai_url: photoStatus.data.ai_url,
});
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
ai_configured: photoStatus.data.ai_configured,
status: enhanceAi.status,
body: enhanceAi.data,
});
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
// и то, что при ai_check:false текст остаётся нетронутым.
const lrGroups = await api('/api/groups', { token });
const gid = lrGroups.data[0] && lrGroups.data[0].id;
if (gid) {
const date = '2019-05-17';
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
const plain = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
});
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
const topicName = 'Циклы for и while';
const withTopic = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: '2019-05-18', lesson_time: '10:00', topic: topicName, text: 'Тема занятия для смоук-теста.', ai_check: false },
});
ok('lesson-report: тема занятия сохраняется при создании',
withTopic.status === 201 && withTopic.data.topic === topicName, { status: withTopic.status, topic: withTopic.data.topic });
const listed = await api(`/api/lesson-reports?group_id=${gid}&date_from=2019-05-18&date_to=2019-05-18`, { token });
ok('lesson-report: тема занятия в списке',
listed.status === 200 && (listed.data.items || []).some(i => i.topic === topicName),
{ status: listed.status, item: (listed.data.items || [])[0] && (listed.data.items || [])[0].topic });
const edited = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: 'Обновлённая тема' },
});
ok('lesson-report: тема занятия обновляется при редактировании',
edited.status === 200 && edited.data.topic === 'Обновлённая тема', { status: edited.status, topic: edited.data.topic });
const cleared = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: '' },
});
ok('lesson-report: тему занятия можно очистить',
cleared.status === 200 && !cleared.data.topic, { status: cleared.status, topic: cleared.data.topic });
const tooLong = await api(`/api/lesson-reports/${withTopic.data.id}`, {
token, method: 'PUT', body: { topic: 'я'.repeat(301) },
});
ok('lesson-report: слишком длинная тема -> 400', tooLong.status === 400, { status: tooLong.status, error: tooLong.data && tooLong.data.error });
await api(`/api/lesson-reports/${withTopic.data.id}`, { token, method: 'DELETE' });
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
{ status: versions.status, items: (versions.data.items || []).length });
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
ok('lesson-report: удаление', del.status === 200, del.status);
} else {
ok('lesson-report: есть группа для проверки', false, 'no groups');
}
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
ok('notifications: настройка lesson.ai.formatted заведена',
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
(lessonNotifyMeta.data.types || []).map(t => t.key));
const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
ok('bad password rejected', badLogin.status === 401, badLogin.status);
console.log('\nAPI SMOKE DONE');
}
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });