1005 lines
38 KiB
TypeScript
1005 lines
38 KiB
TypeScript
|
|
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();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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;');
|
|||
|
|
});
|
|||
|
|
});
|