feat(chat): личный чат тьютор ↔ админ с вложениями

Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat
вместо общей ленты notifications, вложения с белым списком расширений
и вставкой из буфера (Ctrl+V), очистка по chat_retention_days.
Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
dev
2026-10-05 17:30:26 +03:00
parent 3836fc82ef
commit 235fd43af4
12 changed files with 1409 additions and 10 deletions
+40
View File
@@ -305,6 +305,46 @@ async function main() {
(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);
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 });