Files
glchat/web/tests/chat.test.tsx
T
grendervill 20eed227ca feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
2026-09-20 01:32:11 +03:00

1060 lines
40 KiB
TypeScript
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.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
deferred,
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
recordedRequests,
renderApp,
type FetchRoute,
} from './helpers';
import type { Message } from '@/api/messages';
import type { GuildMember, Role } from '@/api/types';
import { MessageContent } from '@/components/chat/MessageContent';
import { channelState, unreadCount, useMessagesStore } from '@/stores/messages';
const user = makeUser();
const members: GuildMember[] = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: ['r-1'],
},
{
user_id: 'user-2',
username: 'bob',
display_name: 'Bob',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-02T10:00:00Z',
role_ids: [],
},
];
const roles: Role[] = [
{
id: 'r-1',
name: 'Администратор',
color: 0x3366ff,
position: 5,
permissions: 'ADMINISTRATOR',
is_default: false,
hoist: true,
mentionable: true,
},
];
const meta = { max_upload_size: 26_214_400 };
const ALL_PERMISSIONS = ['SEND_MESSAGES', 'MANAGE_MESSAGES', 'ATTACH_FILES', 'ADD_REACTIONS'];
/** Локальное время текущего дня: разделители дат не должны зависеть от TZ. */
function todayAt(hour: number, minute = 0, dayOffset = 0): string {
const now = new Date();
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + dayOffset,
hour,
minute,
0,
).toISOString();
}
interface BootOptions {
path?: string;
permissions?: string[];
messages?: Message[];
extraRoutes?: FetchRoute[];
channels?: { id: string; name: string; type?: 'text' | 'voice' }[];
readStates?: unknown[];
}
/** Поднимает приложение на маршруте комнаты и проигрывает рукопожатие шлюза. */
async function boot(options: BootOptions = {}) {
const snapshot = {
...makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: options.permissions ?? ALL_PERMISSIONS,
channels: options.channels ?? [{ id: 'c-1', name: 'общий' }],
},
]),
read_states: options.readStates ?? [],
};
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) },
{ match: '/api/v1/meta', response: () => json(meta) },
...messagesRoutes('c-1', options.messages ?? [], options.extraRoutes ?? []),
]);
const gateway = installGatewaySocket(snapshot);
const view = renderApp(options.path ?? '/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...view, fetchMock, gateway };
}
/** Первое сообщение-заглушка для ленты. */
function message(overrides: Partial<Message> & { id: string }): Message {
return makeMessage({
channel_id: 'c-1',
author_id: 'user-2',
created_at: todayAt(10, 0),
...overrides,
});
}
/** Кнопка в панели действий конкретного сообщения. */
function actionButton(messageId: string, name: string): HTMLElement {
const article = screen.getByTestId(`message-${messageId}`);
return within(article).getByRole('button', { name });
}
/** Открывает контекстное меню сообщения и возвращает пункт по названию. */
async function openMenu(messageId: string, item: string): Promise<HTMLElement> {
const visitor = userEvent.setup();
await visitor.click(actionButton(messageId, 'Действия с сообщением'));
const article = screen.getByTestId(`message-${messageId}`);
const menuItem = within(article).getByRole('menuitem', { name: item });
await visitor.click(menuItem);
return menuItem;
}
describe('чат комнаты: лента', () => {
it('группирует сообщения автора и разделяет дни', async () => {
await boot({
messages: [
message({ id: 'm-1', content: 'вчерашнее', created_at: todayAt(22, 0, -1) }),
message({ id: 'm-2', author_id: 'user-1', content: 'первое' }),
message({ id: 'm-3', author_id: 'user-1', content: 'второе', created_at: todayAt(10, 2) }),
message({ id: 'm-4', author_id: 'user-1', content: 'позже', created_at: todayAt(10, 30) }),
],
});
const list = await screen.findByTestId('message-list');
await waitFor(() => {
expect(within(list).getByText('вчерашнее')).toBeVisible();
});
// Два дня — два разделителя (вчера и сегодня).
const separators = within(list).getAllByTestId('date-separator');
expect(separators.map((item) => item.textContent)).toEqual(['Вчера', 'Сегодня']);
// Три группы: Bob, «первое»+«второе» у Али и «позже» — уже новая группа.
expect(within(list).getAllByTestId('message-group')).toHaveLength(3);
expect(within(list).getAllByText('Аля')).toHaveLength(2);
expect(within(list).getByText('Bob')).toBeVisible();
});
it('показывает пустое состояние в начале комнаты', async () => {
await boot({ messages: [] });
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
});
it('подгружает историю вверх по кнопке', async () => {
const history = Array.from({ length: 50 }, (_, index) =>
message({
id: `m-${String(index + 1)}`,
content: `сообщение ${String(index + 1)}`,
created_at: todayAt(9, index),
}),
);
const older = [
message({ id: 'old-1', content: 'очень старое', created_at: todayAt(8, 0, -1) }),
message({ id: 'old-2', content: 'самое старое', created_at: todayAt(8, 1, -1) }),
];
const { fetchMock } = await boot({
messages: history,
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages?limit=50&before=m-1',
response: () => json({ messages: [...older].reverse() }),
},
],
});
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Загрузить ещё' }));
expect(await screen.findByText('очень старое')).toBeVisible();
expect(findRequest(fetchMock, { url: 'before=m-1' })).toBeDefined();
// Короткая страница — кнопка исчезает.
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Загрузить ещё' })).toBeNull();
});
});
it('оставляет голосовую комнату с заглушкой', async () => {
await boot({
path: '/app/g-1/c-voice',
channels: [
{ id: 'c-1', name: 'общий' },
{ id: 'c-voice', name: 'Голосовая', type: 'voice' },
],
});
expect(await screen.findByText('Голосовая комната')).toBeVisible();
expect(screen.queryByTestId('message-list')).toBeNull();
expect(screen.queryByTestId('composer')).toBeNull();
});
});
describe('чат комнаты: отправка', () => {
it('отправляет сообщение оптимистично и подтверждает ответом сервера', async () => {
const visitor = userEvent.setup();
const created = message({ id: 'm-9', author_id: 'user-1', content: 'привет' });
const send = deferred<Response>();
const { fetchMock } = await boot({
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () => send.promise,
},
],
});
const input = await screen.findByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'привет');
await visitor.keyboard('{Enter}');
// Оптимистичное сообщение видно сразу, до ответа сервера.
const list = screen.getByTestId('message-list');
expect(within(list).getByText('привет')).toBeVisible();
expect(within(list).getByText('отправляется…')).toBeVisible();
const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' });
expect(JSON.parse(JSON.stringify(request?.body))).toMatchObject({ content: 'привет' });
send.resolve(json({ message: created }));
await waitFor(() => {
expect(within(list).queryByText('отправляется…')).toBeNull();
});
expect(within(list).getAllByText('привет')).toHaveLength(1);
// Поле ввода очищено.
expect(input).toHaveValue('');
});
it('возвращает текст и показывает причину при отказе в правах', async () => {
const visitor = userEvent.setup();
await boot({
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () => json({ error: { code: 'perm.denied', message: 'nope' } }, 403),
},
],
});
const input = await screen.findByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'привет');
await visitor.keyboard('{Enter}');
expect(await screen.findByRole('alert')).toHaveTextContent('Недостаточно прав');
expect(input).toHaveValue('привет');
expect(input).toBeDisabled();
});
it('показывает таймер лимита частоты при 429', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () =>
json(
{ error: { code: 'rate_limited', message: 'too many', retry_after_ms: 4_000 } },
429,
),
},
],
});
const input = await screen.findByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'привет');
await visitor.keyboard('{Enter}');
expect(await screen.findByText(/Слишком часто/u)).toHaveTextContent('через 4 с.');
expect(screen.getByRole('button', { name: 'Отправить' })).toBeDisabled();
// Текст во время паузы можно править — блокируется только отправка.
expect(input).toBeEnabled();
expect(input).toHaveValue('привет');
// Повтора автоматически не было: отправка одна, лимит ждём вручную.
const sends = recordedRequests(fetchMock).filter(
(item) => item.method === 'POST' && item.url.includes('/channels/c-1/messages'),
);
expect(sends).toHaveLength(1);
});
it('отправляет уведомление о наборе текста не чаще раза в 3 секунды', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot();
const input = await screen.findByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'п');
await visitor.type(input, 'р');
await visitor.type(input, 'и');
const typingCalls = recordedRequests(fetchMock).filter((item) =>
item.url.includes('/channels/c-1/typing'),
);
expect(typingCalls).toHaveLength(1);
});
});
describe('чат комнаты: вложения', () => {
it('загружает файл и отправляет сообщение с attachment_ids', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
extraRoutes: [
{
match: '/api/v1/channels/c-1/files',
method: 'POST',
response: () =>
json({
file: {
file_id: 'f-1',
filename: 'фото.png',
content_type: 'image/png',
size_bytes: 2048,
url: '/files/f-1',
},
}),
},
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () =>
json({
message: message({
id: 'm-10',
author_id: 'user-1',
content: 'смотри',
attachments: [{ file_id: 'f-1', filename: 'фото.png', content_type: 'image/png' }],
}),
}),
},
],
});
await screen.findByTestId('composer');
await visitor.click(screen.getByRole('button', { name: 'Прикрепить файл' }));
const fileInput = document.querySelector('input[type="file"]');
if (fileInput === null) {
throw new Error('поле выбора файла не найдено');
}
const file = new File(['данные'], 'фото.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(await screen.findByText('фото.png')).toBeVisible();
expect(findRequest(fetchMock, { url: '/channels/c-1/files', method: 'POST' })).toBeDefined();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'смотри');
await visitor.keyboard('{Enter}');
await waitFor(() => {
const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' });
expect(request?.body).toMatchObject({ attachment_ids: ['f-1'] });
});
// После отправки превью вложений очищается.
await waitFor(() => {
expect(screen.queryByLabelText('Вложения к сообщению')).toBeNull();
});
});
});
describe('чат комнаты: реакции и ответы', () => {
it('ставит и снимает реакцию кликом по чипу', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
messages: [
message({
id: 'm-1',
content: 'реагируй',
reactions: [{ emoji: '👍', count: 1, me: false, user_ids: ['user-2'] }],
}),
],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
});
const chip = await screen.findByRole('button', { name: 'Реакция 👍: 1' });
await visitor.click(chip);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция 👍: 2' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })).toBeDefined();
await visitor.click(screen.getByRole('button', { name: 'Реакция 👍: 2' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/reactions/', method: 'DELETE' })).toBeDefined();
});
});
it('ставит реакцию из контекстного меню', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
messages: [message({ id: 'm-1', content: 'меню реакций' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
});
await screen.findByText('меню реакций');
await visitor.click(actionButton('m-1', 'Действия с сообщением'));
await visitor.click(
within(screen.getByTestId('message-m-1')).getByRole('menuitem', {
name: 'Поставить реакцию 🎉',
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция 🎉: 1' })).toBeVisible();
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })).toBeDefined();
});
it('ставит реакцию из быстрых кнопок панели действий', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
messages: [message({ id: 'm-1', content: 'быстрая реакция' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
});
await screen.findByText('быстрая реакция');
await visitor.click(actionButton('m-1', 'Поставить реакцию 🔥'));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция 🔥: 1' })).toBeVisible();
});
expect(String(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })?.url)).toContain(
encodeURIComponent('🔥'),
);
});
it('показывает строку ответа и отправляет сообщение с reply_to_id', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
messages: [
message({ id: 'm-1', content: 'оригинал', author_id: 'user-1' }),
message({ id: 'm-2', content: 'ответ', reply_to_id: 'm-1', created_at: todayAt(10, 5) }),
],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () =>
json({ message: message({ id: 'm-3', author_id: 'user-1', content: 'мой ответ' }) }),
},
],
});
expect(await screen.findByRole('button', { name: /ответ Аля/u })).toBeVisible();
await visitor.click(actionButton('m-1', 'Ответить'));
expect(screen.getByText('Ответ Аля')).toBeVisible();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, 'мой ответ');
await visitor.keyboard('{Enter}');
await waitFor(() => {
const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' });
expect(request?.body).toMatchObject({ content: 'мой ответ', reply_to_id: 'm-1' });
});
// Плашка ответа закрывается после отправки.
await waitFor(() => {
expect(screen.queryByText('Ответ Аля')).toBeNull();
});
});
it('двойной клик по сообщению ставит его в ответ', async () => {
await boot({
messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })],
});
const article = await screen.findByTestId('message-m-1');
fireEvent.doubleClick(article);
// Та же плашка, что и у пункта «Ответить» в меню.
expect(await screen.findByText('Ответ Bob')).toBeVisible();
});
it('двойной клик по ссылке не ставит ответ', async () => {
await boot({
messages: [
message({ id: 'm-1', content: 'смотри https://example.com/page', author_id: 'user-2' }),
],
});
const article = await screen.findByTestId('message-m-1');
fireEvent.doubleClick(within(article).getByRole('link'));
expect(screen.queryByText('Ответ Bob')).toBeNull();
});
it('двойной клик по выделенному тексту не ставит ответ', async () => {
await boot({
messages: [message({ id: 'm-1', content: 'выделенный текст', author_id: 'user-2' })],
});
const article = await screen.findByTestId('message-m-1');
// Пользователь выделил текст — двойной клик принадлежит выделению.
const range = document.createRange();
range.selectNodeContents(article);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
fireEvent.doubleClick(article);
expect(screen.queryByText('Ответ Bob')).toBeNull();
selection?.removeAllRanges();
});
it('двойной клик по кнопке реакции не ставит ответ', async () => {
await boot({
messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })],
});
const article = await screen.findByTestId('message-m-1');
fireEvent.doubleClick(within(article).getByRole('button', { name: 'Поставить реакцию 🔥' }));
expect(screen.queryByText('Ответ Bob')).toBeNull();
});
});
describe('чат комнаты: права и действия', () => {
it('редактирует своё сообщение и показывает метку «изменено»', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot({
messages: [message({ id: 'm-1', content: 'старое', author_id: 'user-1' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/m-1',
method: 'PATCH',
response: () =>
json({
message: message({
id: 'm-1',
author_id: 'user-1',
content: 'новое',
edited_at: todayAt(11, 0),
}),
}),
},
],
});
await screen.findByText('старое');
await openMenu('m-1', 'Изменить');
const editor = screen.getByLabelText('Редактирование сообщения');
await visitor.clear(editor);
await visitor.type(editor, 'новое');
await visitor.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('новое')).toBeVisible();
expect(screen.getByText('(изменено)')).toBeVisible();
expect(
findRequest(fetchMock, { url: '/channels/c-1/messages/m-1', method: 'PATCH' }),
).toBeDefined();
});
it('удаляет своё сообщение после подтверждения', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { fetchMock } = await boot({
messages: [message({ id: 'm-1', content: 'удали меня', author_id: 'user-1' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/m-1',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
});
await screen.findByText('удали меня');
await openMenu('m-1', 'Удалить');
expect(confirm).toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByText('удали меня')).toBeNull();
});
expect(
findRequest(fetchMock, { url: '/channels/c-1/messages/m-1', method: 'DELETE' }),
).toBeDefined();
});
it('без MANAGE_MESSAGES не предлагает удалять чужие сообщения', async () => {
await boot({
permissions: ['SEND_MESSAGES'],
messages: [
message({ id: 'm-1', content: 'чужое', author_id: 'user-2' }),
message({ id: 'm-2', content: 'моё', author_id: 'user-1', created_at: todayAt(10, 1) }),
],
});
await screen.findByText('чужое');
const visitor = userEvent.setup();
await visitor.click(actionButton('m-1', 'Действия с сообщением'));
expect(
within(screen.getByTestId('message-m-1')).queryByRole('menuitem', { name: 'Удалить' }),
).toBeNull();
expect(
within(screen.getByTestId('message-m-1')).queryByRole('menuitem', { name: 'Закрепить' }),
).toBeNull();
await visitor.keyboard('{Escape}');
await visitor.click(actionButton('m-2', 'Действия с сообщением'));
expect(
within(screen.getByTestId('message-m-2')).getByRole('menuitem', { name: 'Удалить' }),
).toBeVisible();
});
it('модератор с MANAGE_MESSAGES может удалить чужое сообщение', async () => {
await boot({ messages: [message({ id: 'm-1', content: 'чужое', author_id: 'user-2' })] });
await screen.findByText('чужое');
const visitor = userEvent.setup();
await visitor.click(actionButton('m-1', 'Действия с сообщением'));
expect(
within(screen.getByTestId('message-m-1')).getByRole('menuitem', { name: 'Удалить' }),
).toBeVisible();
});
it('закрывает композер, если комната только для чтения', async () => {
await boot({
channels: [{ id: 'c-1', name: 'общий' }],
permissions: [],
messages: [],
extraRoutes: [],
});
const input = await screen.findByLabelText('Сообщение в комнату общий');
expect(input).toBeDisabled();
expect(screen.getByText('В эту комнату нельзя писать.')).toBeVisible();
});
});
describe('чат комнаты: события шлюза', () => {
it('применяет MESSAGE_CREATE/UPDATE/DELETE из шлюза', async () => {
const { gateway } = await boot({ messages: [message({ id: 'm-1', content: 'исходное' })] });
await screen.findByText('исходное');
gateway.dispatch(
'MESSAGE_CREATE',
message({ id: 'm-2', content: 'новое из шлюза', created_at: todayAt(10, 5) }),
);
expect(await screen.findByText('новое из шлюза')).toBeVisible();
gateway.dispatch(
'MESSAGE_UPDATE',
message({ id: 'm-1', content: 'изменённое', edited_at: todayAt(10, 6) }),
);
expect(await screen.findByText('изменённое')).toBeVisible();
expect(screen.queryByText('исходное')).toBeNull();
gateway.dispatch('MESSAGE_DELETE', { id: 'm-2', channel_id: 'c-1' });
await waitFor(() => {
expect(screen.queryByText('новое из шлюза')).toBeNull();
});
});
it('применяет реакции, «печатает…» и отметки прочтения', async () => {
const { gateway } = await boot({
messages: [message({ id: 'm-1', content: 'лента' })],
channels: [
{ id: 'c-1', name: 'общий' },
{ id: 'c-2', name: 'второй' },
],
});
await screen.findByText('лента');
gateway.dispatch('MESSAGE_REACTION_ADD', {
channel_id: 'c-1',
message_id: 'm-1',
user_id: 'user-2',
emoji: '🎉',
});
expect(await screen.findByRole('button', { name: 'Реакция 🎉: 1' })).toBeVisible();
gateway.dispatch('MESSAGE_REACTION_REMOVE', {
channel_id: 'c-1',
message_id: 'm-1',
user_id: 'user-2',
emoji: '🎉',
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Реакция 🎉: 1' })).toBeNull();
});
gateway.dispatch('TYPING_START', { channel_id: 'c-1', user_id: 'user-2' });
expect(await screen.findByTestId('typing-indicator')).toHaveTextContent('Bob печатает…');
gateway.dispatch('READ_STATE_UPDATE', { channel_id: 'c-2', mention_count: 2 });
expect(await screen.findByTestId('channel-mentions-c-2')).toHaveTextContent('2');
});
it('события чужой комнаты не попадают в открытую ленту, но дают бейдж', async () => {
const { gateway } = await boot({
messages: [message({ id: '100', content: 'лента' })],
channels: [
{ id: 'c-1', name: 'общий' },
{ id: 'c-2', name: 'второй' },
],
});
await screen.findByText('лента');
gateway.dispatch(
'MESSAGE_CREATE',
message({ id: '101', channel_id: 'c-2', content: 'чужая комната' }),
);
gateway.dispatch('READ_STATE_UPDATE', {
channel_id: 'c-2',
last_message_id: '100',
mention_count: 0,
});
await waitFor(() => {
expect(screen.queryByText('чужая комната')).toBeNull();
});
expect(
channelState(useMessagesStore.getState(), 'c-2').messages.map((item) => item.id),
).toEqual(['101']);
// Комната не открыта, но счётчик непрочитанных виден в сайдбаре.
expect(await screen.findByTestId('channel-unread-c-2')).toHaveTextContent('1');
});
});
describe('чат комнаты: сессия', () => {
it('очищает ленту сообщений при выходе из аккаунта', async () => {
const visitor = userEvent.setup();
await boot({
messages: [message({ id: 'm-1', content: 'личное' })],
extraRoutes: [
{ match: '/api/v1/auth/logout', method: 'POST', response: () => json({ ok: true }) },
],
});
await screen.findByText('личное');
expect(Object.keys(useMessagesStore.getState().channels)).toContain('c-1');
await visitor.click(screen.getByLabelText('Открыть меню пользователя'));
await visitor.click(screen.getByRole('menuitem', { name: 'Выйти' }));
// Чужая история не должна пережить смену пользователя.
await waitFor(() => {
expect(useMessagesStore.getState().channels).toEqual({});
});
});
});
describe('чат комнаты: непрочитанное', () => {
it('не теряет разделитель, если история загрузилась раньше READY', async () => {
// Пока шлюз молчит, сервер и комнаты приходят по REST (fallback-ветка),
// поэтому история успевает загрузиться до снапшота с отметками прочтения.
const snapshot = {
...makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ALL_PERMISSIONS,
channels: [{ id: 'c-1', name: 'общий' }],
},
]),
read_states: [{ channel_id: 'c-1', last_message_id: 'm-1', mention_count: 0 }],
};
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) },
{
match: '/api/v1/guilds/g-1/channels',
response: () =>
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
},
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Сервер',
description: '',
owner_id: 'user-1',
is_main: true,
member_count: 2,
roles,
my_role_ids: [],
my_permissions: ALL_PERMISSIONS,
},
}),
},
...messagesRoutes('c-1', [
message({ id: 'm-1', content: 'прочитано' }),
message({ id: 'm-2', content: 'новое', created_at: todayAt(10, 1) }),
message({ id: 'm-3', content: 'свежее', created_at: todayAt(10, 2) }),
]),
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
// История уже на экране, а снапшот READY ещё не пришёл.
expect(await screen.findByText('свежее')).toBeVisible();
expect(screen.queryByTestId('unread-separator')).toBeNull();
await gateway.greet();
// Отметка прочтения пришла со снапшотом — разделитель встаёт на место.
expect(await screen.findByTestId('unread-separator')).toBeVisible();
await waitFor(
() => {
expect(findRequest(fetchMock, { url: '/channels/c-1/ack', method: 'POST' })).toBeDefined();
},
{ timeout: 2500 },
);
});
it('ставит разделитель новых сообщений и отправляет ack', async () => {
const { fetchMock } = await boot({
messages: [
message({ id: 'm-1', content: 'прочитано' }),
message({ id: 'm-2', content: 'новое', created_at: todayAt(10, 1) }),
message({ id: 'm-3', content: 'свежее', created_at: todayAt(10, 2) }),
],
channels: [
{ id: 'c-1', name: 'общий' },
{ id: 'c-2', name: 'второй' },
],
readStates: [
{ channel_id: 'c-1', last_message_id: 'm-1', mention_count: 1 },
{ channel_id: 'c-2', mention_count: 2 },
],
});
expect(await screen.findByTestId('unread-separator')).toHaveTextContent('Новые сообщения');
expect(await screen.findByTestId('channel-mentions-c-2')).toHaveTextContent('2');
await waitFor(
() => {
expect(findRequest(fetchMock, { url: '/channels/c-1/ack', method: 'POST' })).toBeDefined();
},
{ timeout: 2500 },
);
expect(
findRequest(fetchMock, { url: '/channels/c-1/ack', method: 'POST' })?.body,
).toMatchObject({ last_message_id: 'm-3' });
await waitFor(() => {
const record = channelState(useMessagesStore.getState(), 'c-1');
expect(unreadCount(record)).toBe(0);
expect(record.mentionCount).toBe(0);
});
});
});
describe('чат комнаты: пины и поиск', () => {
it('открывает панель закреплённых сообщений', async () => {
const visitor = userEvent.setup();
await boot({
messages: [
message({ id: 'm-1', content: 'важное сообщение', pinned: true, author_id: 'user-1' }),
message({ id: 'm-2', content: 'обычное', created_at: todayAt(10, 5) }),
],
});
await screen.findByText('важное сообщение');
await visitor.click(screen.getByRole('button', { name: 'Закреплённые сообщения' }));
const dialog = await screen.findByRole('dialog', { name: 'Закреплённые в #общий' });
expect(within(dialog).getByText('важное сообщение')).toBeVisible();
expect(within(dialog).getByRole('button', { name: 'Перейти' })).toBeVisible();
});
it('ищет по комнате и показывает результаты', async () => {
const visitor = userEvent.setup();
await boot({
messages: [message({ id: 'm-1', content: 'обычное' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/search',
response: () =>
json({
messages: [message({ id: 'm-1', content: 'нашлось слово', author_id: 'user-1' })],
}),
},
],
});
await screen.findByText('обычное');
await visitor.click(screen.getByRole('button', { name: 'Поиск по комнате' }));
const panel = await screen.findByTestId('search-panel');
await visitor.type(within(panel).getByLabelText('Строка поиска'), 'слово');
expect(await within(panel).findByRole('mark', {}, { timeout: 2000 })).toHaveTextContent(
'слово',
);
});
it('переходит к результату поиска, подгружая историю до него', async () => {
const visitor = userEvent.setup();
// Идентификаторы и время растут вместе: самое старое сообщение — 451.
const history = Array.from({ length: 50 }, (_, index) =>
message({
id: String(451 + index),
content: `строка ${String(451 + index)}`,
created_at: todayAt(9, index),
}),
);
const deep = message({
id: '440',
content: 'глубокое сообщение',
created_at: todayAt(8, 0, -1),
});
const filler = message({ id: '441', content: 'наполнитель', created_at: todayAt(8, 1, -1) });
const { fetchMock } = await boot({
messages: history,
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages?limit=50&before=451',
response: () => json({ messages: [filler, deep] }),
},
{
match: '/api/v1/channels/c-1/messages/search',
response: () => json({ messages: [deep] }),
},
],
});
await screen.findByText('строка 500');
await visitor.click(screen.getByRole('button', { name: 'Поиск по комнате' }));
const panel = await screen.findByTestId('search-panel');
await visitor.type(within(panel).getByLabelText('Строка поиска'), 'глубокое');
await within(panel).findByRole('mark', {}, { timeout: 2000 });
const result = await within(panel).findByRole('button', { name: /глубокое/u });
await visitor.click(result);
// Цель была выше окна: клиент догрузил историю и подсветил сообщение.
const target = await screen.findByTestId('message-440');
expect(target).toHaveClass('gl-highlight');
expect(findRequest(fetchMock, { url: 'before=451' })).toBeDefined();
});
it('показывает ошибку лимита частоты при поиске', async () => {
const visitor = userEvent.setup();
await boot({
messages: [message({ id: 'm-1', content: 'обычное' })],
extraRoutes: [
{
match: '/api/v1/channels/c-1/messages/search',
response: () =>
json(
{ error: { code: 'rate_limited', message: 'too many', retry_after_ms: 6_000 } },
429,
),
},
],
});
await screen.findByText('обычное');
await visitor.click(screen.getByRole('button', { name: 'Поиск по комнате' }));
const panel = await screen.findByTestId('search-panel');
await visitor.type(within(panel).getByLabelText('Строка поиска'), 'слово');
expect(await within(panel).findByRole('alert')).toHaveTextContent('Слишком много запросов');
});
});
describe('рендер разметки сообщения', () => {
it('собирает React-узлы и не выполняет HTML из текста', () => {
const { container } = render(
<MessageContent
content={'**жирный** *курсив* `код` ~~зачёркнутый~~\n> цитата\n<script>alert(1)</script>'}
resolveMention={() => ({ name: 'Bob', isMe: false })}
/>,
);
expect(container.querySelector('strong')).toHaveTextContent('жирный');
expect(container.querySelector('em')).toHaveTextContent('курсив');
expect(container.querySelector('code')).toHaveTextContent('код');
expect(container.querySelector('s')).toHaveTextContent('зачёркнутый');
expect(container.querySelector('blockquote')).toHaveTextContent('цитата');
// HTML из сообщения остаётся текстом.
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('<script>alert(1)</script>');
});
it('рендерит упоминания, ссылки и блок кода', () => {
const { container } = render(
<MessageContent
content={'<@42> смотри https://example.com/a.\n```js\nconst a = 1;\n```'}
resolveMention={(id) => ({ name: id === '42' ? 'Аля' : id, isMe: true })}
/>,
);
expect(container.textContent).toContain('@Аля');
const link = container.querySelector('a');
expect(link).toHaveAttribute('href', 'https://example.com/a');
expect(link).toHaveAttribute('rel', 'noreferrer noopener');
expect(container.querySelector('pre')?.textContent).toContain('const a = 1;');
});
});