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 & { 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 { 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(); 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( цитата\n'} 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(''); }); it('рендерит упоминания, ссылки и блок кода', () => { const { container } = render( смотри 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;'); }); });