feat(chat): правка и удаление сообщений
- PUT/DELETE /api/chat/threads/:id/messages/:messageId под requireAuth+chatLimiter, доступ через chatMessageFor (тред + принадлежность сообщения) - править может только автор (админу чужое сообщение 403), удалять — автор или админ - мягкое удаление: body='', вложения каскадом из БД + safeUnlink по path, счётчик непрочитанных уменьшается на 1 у своей стороны (GREATEST(col-1,0)) - refreshChatThreadPreview() пересчитывает превью треда по последнему живому сообщению - SSE: publishChat с type, список типов валидируется через CHAT_EVENTS; новые message_update/message_delete подписаны в connectChatStream (admin.js) - аудит chat.message.update с textDiff и chat.message.delete без текста - колонки edited_at/deleted_at в db/init.sql, db/migration.sql, ensureChatTables() и в normalizeRestoreData + restore-INSERT — удалённые сообщения не воскресают - фронтенд: кнопки правки/удаления по наведению, модалка правки (Ctrl+Enter), метка «изменено», плейсхолдер «Сообщение удалено», обновление по SSE - покрытие в api.smoketest.js (правка/удаление/403/400/404) и backup.selftest.js
This commit is contained in:
@@ -345,6 +345,81 @@ async function main() {
|
||||
const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } });
|
||||
ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.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 });
|
||||
|
||||
Reference in New Issue
Block a user