Files
glchat/web/tests/chat.test.tsx
T

1060 lines
40 KiB
TypeScript
Raw Normal View History

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;');
});
});