1062 lines
40 KiB
TypeScript
1062 lines
40 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.findByTestId('voice-join')).toBeVisible();
|
||
expect(screen.getByText('В комнате пока никого нет')).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;');
|
||
});
|
||
});
|