Админ может скрыть диалог тьютора (мягкое удаление), снять скрытие, отменить плановое удаление или удалить диалог физически через TTL общей настройки корзины. - chat_threads: deleted_at, purge_at (init.sql, migration.sql, ensureChatTables) - маршруты hide / restore / unschedule / permanent под requireAdmin + аудит - purgeScheduledDeletions() + hardDeleteChatThread() с safeUnlink по вложениям - скрытые диалоги видны тьютору как обычно; новое сообщение тьютора авто-возвращает диалог в список (thread_update по SSE) - UI: кнопка «Скрытые» с бейджем и действия в карточке треда - бэкап: deleted_at/purge_at в normalizeRestoreData
471 lines
35 KiB
JavaScript
471 lines
35 KiB
JavaScript
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));
|
||
|
||
// --- Chat (тьютор ↔ админ) ---
|
||
const chatNoAuth = await api('/api/chat/threads');
|
||
ok('chat: список без токена -> 401', chatNoAuth.status === 401, chatNoAuth.status);
|
||
const chatThreads = await api('/api/chat/threads', { token });
|
||
ok('chat: список диалогов -> 200', chatThreads.status === 200 && Array.isArray(chatThreads.data.items) && typeof chatThreads.data.unread === 'number', chatThreads.status);
|
||
const chatUnreadGet = await api('/api/chat/unread', { token });
|
||
ok('chat: счётчик непрочитанных', chatUnreadGet.status === 200 && typeof chatUnreadGet.data.unread === 'number', chatUnreadGet.data);
|
||
const chatStream = await fetch(BASE + '/api/chat/stream?token=' + encodeURIComponent(token));
|
||
ok('chat: SSE открывается', chatStream.status === 200, chatStream.status);
|
||
if (chatStream.status === 200) {
|
||
const chatReader = chatStream.body.getReader();
|
||
const chatFirst = await chatReader.read();
|
||
const chatText = new TextDecoder().decode(chatFirst.value || new Uint8Array());
|
||
ok('chat: SSE отдаёт ready-кадр', chatText.includes('event: ready') || chatText.includes(':ok'), JSON.stringify(chatText.slice(0, 60)));
|
||
chatReader.cancel().catch(() => {});
|
||
}
|
||
const chatBadId = await api('/api/chat/threads/not-a-number/messages', { token });
|
||
ok('chat: некорректный id диалога -> 400', chatBadId.status === 400, chatBadId.status);
|
||
const chatMissing = await api('/api/chat/threads/99999999/messages', { token });
|
||
ok('chat: несуществующий диалог -> 404', chatMissing.status === 404, chatMissing.status);
|
||
const chatAdminThread = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: me1.data.id } });
|
||
ok('chat: админ создаёт диалог с пользователем', chatAdminThread.status === 200 && chatAdminThread.data.thread && chatAdminThread.data.thread.id > 0, chatAdminThread.data);
|
||
if (chatAdminThread.status === 200 && chatAdminThread.data.thread) {
|
||
const tid = chatAdminThread.data.thread.id;
|
||
const chatMsg = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: 'Smoke: чат работает' } });
|
||
ok('chat: отправка сообщения', chatMsg.status === 200 && chatMsg.data.message && chatMsg.data.message.id > 0, chatMsg.data);
|
||
const chatMsgList = await api(`/api/chat/threads/${tid}/messages`, { token });
|
||
ok('chat: чтение сообщений', chatMsgList.status === 200 && Array.isArray(chatMsgList.data.items) && chatMsgList.data.items.some(m => m.body === 'Smoke: чат работает'), (chatMsgList.data.items || []).map(m => m.body));
|
||
const chatEmpty = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: ' ' } });
|
||
ok('chat: пустое сообщение -> 400', chatEmpty.status === 400, chatEmpty.status);
|
||
const chatRead = await api(`/api/chat/threads/${tid}/read`, { token, method: 'POST' });
|
||
ok('chat: отметка прочитанным', chatRead.status === 200 && typeof chatRead.data.unread === 'number', chatRead.data);
|
||
const chatFileNoAuth = await fetch(BASE + '/api/chat/files/deadbeef');
|
||
ok('chat: файл без токена -> 401', chatFileNoAuth.status === 401, chatFileNoAuth.status);
|
||
const chatFileMissing = await api('/api/chat/files/deadbeef', { token });
|
||
ok('chat: несуществующий файл -> 404', chatFileMissing.status === 404, chatFileMissing.status);
|
||
}
|
||
const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } });
|
||
ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status);
|
||
|
||
// --- Chat: скрытие/восстановление диалога (только админ) ---
|
||
const chatHideThread = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: me1.data.id } });
|
||
if (chatHideThread.status === 200 && chatHideThread.data.thread) {
|
||
const hid = chatHideThread.data.thread.id;
|
||
const hideOk = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
|
||
ok('chat: админ скрывает диалог', hideOk.status === 200, hideOk.status);
|
||
const hideAgain = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
|
||
ok('chat: повторное скрытие -> 400', hideAgain.status === 400, hideAgain.status);
|
||
const listActive = await api('/api/chat/threads', { token });
|
||
ok('chat: скрытый диалог ушёл из активного списка',
|
||
listActive.status === 200 && !(listActive.data.items || []).some(t => t.id === hid)
|
||
&& listActive.data.hidden >= 1 && listActive.data.hidden_mode === false,
|
||
{ hidden: listActive.data.hidden, mode: listActive.data.hidden_mode });
|
||
const listHidden = await api('/api/chat/threads?deleted=1', { token });
|
||
ok('chat: скрытый диалог виден в скрытых',
|
||
listHidden.status === 200 && (listHidden.data.items || []).some(t => t.id === hid)
|
||
&& listHidden.data.hidden_mode === true && !!(listHidden.data.items || []).find(t => t.id === hid).deleted_at,
|
||
(listHidden.data.items || []).map(t => t.id));
|
||
const messagesHidden = await api(`/api/chat/threads/${hid}/messages`, { token });
|
||
ok('chat: сообщения скрытого диалога читаются', messagesHidden.status === 200 && Array.isArray(messagesHidden.data.items), messagesHidden.status);
|
||
const restore = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
|
||
ok('chat: восстановление диалога', restore.status === 200, restore.status);
|
||
const restoreAgain = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
|
||
ok('chat: повторное восстановление -> 400', restoreAgain.status === 400, restoreAgain.status);
|
||
const hideSchedule = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
|
||
ok('chat: скрытие перед планированием удаления', hideSchedule.status === 200, hideSchedule.status);
|
||
const purge = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' });
|
||
ok('chat: планирование удаления диалога', purge.status === 200, purge.status);
|
||
const purgeAgain = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' });
|
||
ok('chat: повторное планирование продлевает срок', purgeAgain.status === 200, purgeAgain.status);
|
||
const restoreScheduled = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
|
||
ok('chat: восстановление снимает планирование', restoreScheduled.status === 200, restoreScheduled.status);
|
||
}
|
||
const chatHideNoAuth = await api(`/api/chat/threads/${chatHideThread.data?.thread?.id || 1}`, { method: 'DELETE' });
|
||
ok('chat: скрытие без токена -> 401', chatHideNoAuth.status === 401, chatHideNoAuth.status);
|
||
|
||
// --- Chat: правка и удаление сообщений ---
|
||
const tutorLogin = 'smoke_chat_' + Date.now().toString(36);
|
||
const tutorPass = 'SmokeChat123!';
|
||
const tutorCreate = await api('/api/users', { token, method: 'POST', body: { username: tutorLogin, password: tutorPass, name: 'Smoke Chat', role: 'tutor' } });
|
||
ok('chat-edit: создан тестовый тьютор', tutorCreate.status === 201 && tutorCreate.data.id > 0, tutorCreate.status);
|
||
if (tutorCreate.status === 201) {
|
||
const tutorId = tutorCreate.data.id;
|
||
const tutorAuth = await api('/api/auth/login', { method: 'POST', body: { username: tutorLogin, password: tutorPass } });
|
||
ok('chat-edit: тьютор вошёл', tutorAuth.status === 200 && !!tutorAuth.data.token, tutorAuth.status);
|
||
const tutorToken = tutorAuth.data.token;
|
||
const tutorMe = await api('/api/auth/me', { token: tutorToken });
|
||
ok('chat-edit: сессия тьютора жива', tutorMe.status === 200 && tutorMe.data.role === 'tutor', tutorMe.status);
|
||
|
||
const tThread = await api('/api/chat/threads', { token: tutorToken, method: 'POST', body: {} });
|
||
ok('chat-edit: тьютор создал свой диалог', tThread.status === 200 && tThread.data.thread && tThread.data.thread.id > 0, tThread.status);
|
||
const tid = tThread.data && tThread.data.thread ? tThread.data.thread.id : 0;
|
||
const sent = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken, method: 'POST', body: { body: 'Smoke: исходный текст' } });
|
||
ok('chat-edit: тьютор отправил сообщение', sent.status === 200 && sent.data.message && sent.data.message.id > 0, sent.status);
|
||
const mid = sent.data && sent.data.message ? sent.data.message.id : 0;
|
||
|
||
const foreignEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token, method: 'PUT', body: { body: 'Админ правит чужое' } });
|
||
ok('chat-edit: админ не может править чужое сообщение -> 403', foreignEdit.status === 403, foreignEdit.status);
|
||
|
||
const edited = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'Smoke: изменённый текст' } });
|
||
ok('chat-edit: автор правит своё сообщение', edited.status === 200 && edited.data.message && edited.data.message.body === 'Smoke: изменённый текст' && !!edited.data.message.edited_at, edited.data);
|
||
|
||
const emptyEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: ' ' } });
|
||
ok('chat-edit: пустой текст -> 400', emptyEdit.status === 400, emptyEdit.status);
|
||
|
||
const editNoAuth = await api(`/api/chat/threads/${tid}/messages/${mid}`, { method: 'PUT', body: { body: 'без токена' } });
|
||
ok('chat-edit: правка без токена -> 401', editNoAuth.status === 401, editNoAuth.status);
|
||
|
||
const editMissing = await api(`/api/chat/threads/${tid}/messages/99999999`, { token: tutorToken, method: 'PUT', body: { body: 'нет такого' } });
|
||
ok('chat-edit: несуществующее сообщение -> 404', editMissing.status === 404, editMissing.status);
|
||
|
||
const listEdited = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken });
|
||
const listed = (listEdited.data.items || []).find(m => m.id === mid);
|
||
ok('chat-edit: edited_at виден в ленте', !!listed && !!listed.edited_at && listed.body === 'Smoke: изменённый текст', listed);
|
||
|
||
const adminReply = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: 'Smoke: ответ админа' } });
|
||
ok('chat-edit: админ ответил в диалоге', adminReply.status === 200 && adminReply.data.message, adminReply.status);
|
||
const adminMsgId = adminReply.data && adminReply.data.message ? adminReply.data.message.id : 0;
|
||
const tutorDelForeign = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token: tutorToken, method: 'DELETE' });
|
||
ok('chat-del: тьютор не может удалить сообщение админа -> 403', tutorDelForeign.status === 403, tutorDelForeign.status);
|
||
|
||
const delOwn = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' });
|
||
ok('chat-del: автор удаляет своё сообщение', delOwn.status === 200 && delOwn.data.ok === true, delOwn.data);
|
||
|
||
const delAgain = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' });
|
||
ok('chat-del: повторное удаление -> 404', delAgain.status === 404, delAgain.status);
|
||
|
||
const editDeleted = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'воскрешение' } });
|
||
ok('chat-edit: правка удалённого -> 400', editDeleted.status === 400, editDeleted.status);
|
||
|
||
const listDeleted = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken });
|
||
const gone = (listDeleted.data.items || []).find(m => m.id === mid);
|
||
ok('chat-del: удалённое сообщение без тела и вложений', !!gone && !!gone.deleted_at && !gone.body && Array.isArray(gone.files) && !gone.files.length, gone);
|
||
|
||
const threadAfter = await api('/api/chat/threads', { token });
|
||
const previewThread = (threadAfter.data.items || []).find(t => t.id === tid);
|
||
ok('chat-del: превью треда пересчитано', !!previewThread && previewThread.last_message_text === 'Smoke: ответ админа', previewThread && previewThread.last_message_text);
|
||
|
||
const adminModeration = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token, method: 'DELETE' });
|
||
ok('chat-del: админ удаляет чужое сообщение (модерация)', adminModeration.status === 200 && adminModeration.data.ok === true, adminModeration.status);
|
||
|
||
const emptyThreadPreview = await api('/api/chat/threads', { token });
|
||
const emptyPreview = (emptyThreadPreview.data.items || []).find(t => t.id === tid);
|
||
ok('chat-del: превью очищено, когда все сообщения удалены', !!emptyPreview && !emptyPreview.last_message_text, emptyPreview && emptyPreview.last_message_text);
|
||
|
||
const noAuthDel = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { method: 'DELETE' });
|
||
ok('chat-del: удаление без токена -> 401', noAuthDel.status === 401, noAuthDel.status);
|
||
|
||
await api(`/api/users/${tutorId}`, { token, method: 'DELETE' });
|
||
}
|
||
|
||
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); });
|