Files
glchat/web/tests/guildEmojis.test.tsx
T
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00

563 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import type { Message } from '@/api/messages';
import { parseGuildEmojisUpdate } from '@/api/gatewayEvents';
import { parseGuildEmojiList } from '@/api/emojis';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { ComposerMirror } from '@/components/chat/ComposerMirror';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import { parseInline } from '@/lib/markdown';
import { dispatchGatewayEvent } from '@/stores/gateway';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import {
createTestQueryClient,
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Кастомные эмодзи сервера (AGENT.md 7.12): токен `<:имя:file_id>` в разметке,
* лента и зеркало рисуют картинку, пикер и автокомплит `:` вставляют токен,
* реакции ходят тем же токеном, а набор эмодзи живёт в снапшоте и обновляется
* событием `GUILD_EMOJIS_UPDATE`.
*/
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
token: '<a:party_parrot:901>',
};
/** Сервер снапшота с кастомными эмодзи. */
function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): SessionGuild {
return {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis,
sounds: [],
};
}
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveWebhook: () => ({
id: '',
name: 'вебхук',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
resolveMemberName: (userId) => `user ${userId}`,
loaded: true,
};
const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
beforeEach(() => {
resetStores();
});
afterEach(() => {
resetStores();
});
describe('кастомные эмодзи: разбор токена', () => {
it('распознаёт <:имя:id> и <a:имя:id>', () => {
const tokens = parseInline('привет <:pepe:900> и <a:party_parrot:901>');
expect(tokens.map((token) => token.kind)).toEqual(['custom-emoji', 'custom-emoji']);
expect(tokens.map((token) => token.raw)).toEqual(['<:pepe:900>', '<a:party_parrot:901>']);
expect(parseCustomEmoji('<:pepe:900>')).toEqual({
name: 'pepe',
fileId: '900',
animated: false,
});
expect(parseCustomEmoji('<a:party_parrot:901>')).toEqual({
name: 'party_parrot',
fileId: '901',
animated: true,
});
expect(customEmojiShortcode('pepe')).toBe(':pepe:');
});
it('битый токен остаётся текстом и не ломает разметку', () => {
const content = '**жирный** <:pepe:> <:p:1> <:pepe:abc> <:имя:1>';
expect(parseInline(content)).toHaveLength(1);
expect(parseInline(content)[0]?.kind).toBe('bold');
expect(parseCustomEmoji('<:pepe:>')).toBeNull();
expect(parseCustomEmoji('<:pepe:abc>')).toBeNull();
expect(parseCustomEmoji('<:p:1>')).toBeNull();
});
it('не конфликтует с упоминаниями и ссылками на комнаты', () => {
const kinds = parseInline('<@42> <#7> <:pepe:900>').map((token) => token.kind);
expect(kinds).toEqual(['mention', 'channel-link', 'custom-emoji']);
});
it('разбирает список эмодзи из ответа сервера', () => {
const parsed = parseGuildEmojiList([
{ id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' },
// Токен не пришёл — собираем сами по тому же формату.
{ id: 'e-2', name: 'kek', file_id: '902', animated: true },
// Битая запись отбрасывается.
{ id: 'e-3', name: 'no_file' },
]);
expect(parsed.map((emoji) => emoji.token)).toEqual(['<:pepe:900>', '<a:kek:902>']);
});
});
describe('кастомные эмодзи: лента сообщений', () => {
it('рисует картинку, если эмодзи есть в снапшоте сервера', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const { container } = render(<MessageContent content={'привет <:pepe:900>!'} />);
const image = container.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveAttribute('alt', ':pepe:');
expect(image).toHaveAttribute('title', ':pepe:');
expect(image).toHaveAttribute('loading', 'lazy');
expect(image).toHaveClass('h-5', 'w-5');
expect(container.textContent).toBe('привет !');
});
it('неизвестный файл показывается текстом :имя:, разметка остаётся целой', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const { container } = render(
<MessageContent content={'**жирный** и <:pepe:900> с <a:party_parrot:901>'} />,
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('strong')).toHaveTextContent('жирный');
expect(container.textContent).toBe('жирный и :pepe: с :party_parrot:');
});
it('без снапшота сервера (личная беседа) токен остаётся текстом', () => {
const { container } = render(<MessageContent content="<:pepe:900>" />);
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: зеркало композера', () => {
it('вне каретки рисует картинку, внутри токена — исходный текст', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const mirrorRef = createRef<HTMLDivElement>();
const value = 'эмодзи <:pepe:900>';
const { rerender } = render(
<ComposerMirror value={value} selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
const image = mirrorRef.current?.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveClass('h-5', 'w-5', 'align-[-0.15em]');
// Каретка внутри токена — показываем маркеры (весь токен) для правки.
rerender(
<ComposerMirror value={value} selection={{ start: 10, end: 10 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(value);
});
it('неизвестный эмодзи показывается текстом :имя:', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const mirrorRef = createRef<HTMLDivElement>();
render(
<ComposerMirror value="<:pepe:900>" selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: пикер', () => {
it('показывает эмодзи сервера, ищет по имени и отдаёт токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const picked: string[] = [];
render(
<EmojiPicker
open
guildId="g-1"
onClose={() => undefined}
onPick={(emoji) => picked.push(emoji)}
/>,
);
const section = screen.getByTestId('emoji-picker-server');
expect(within(section).getByText(':pepe:')).toBeVisible();
expect(within(section).getByText(':party_parrot:')).toBeVisible();
expect(section.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(within(section).getByText('анимированный')).toBeVisible();
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
expect(within(section).queryByText(':pepe:')).toBeNull();
await visitor.click(
within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }),
);
expect(picked).toEqual(['<a:party_parrot:901>']);
});
it('без сервера секция эмодзи сервера не показывается', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />);
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
});
});
describe('кастомные эмодзи: пикер в композере', () => {
it('вставляет токен по клику в позицию каретки', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId="g-1"
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.click(screen.getByRole('button', { name: 'Эмодзи' }));
await visitor.click(screen.getByRole('button', { name: 'Выбрать эмодзи pepe' }));
expect(input).toHaveValue('<:pepe:900>');
// Зеркало сразу показывает картинку — предпросмотр совпадает с лентой.
await waitFor(() => {
expect(screen.getByTestId('composer-mirror').querySelector('img')).toHaveAttribute(
'src',
'/files/900',
);
});
});
});
describe('кастомные эмодзи: автокомплит (:)', () => {
function renderComposer(guildId: string | null = 'g-1') {
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
const view = render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId={guildId}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
return { ...view, fetchMock };
}
it('дополняет список эмодзи сервера и вставляет токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
const list = await screen.findByTestId('composer-autocomplete');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:pepe:900>');
});
it('точное совпадение по имени выше юникод-словаря', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({
guilds: [
guildWithEmojis([
{ id: 'e-3', name: 'fire', file_id: '950', animated: false, token: '<:fire:950>' },
]),
],
});
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':fire');
const options = await screen.findAllByTestId('composer-option-emoji');
expect(options[0]).toHaveTextContent(':fire:');
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:fire:950>');
});
it('в личной беседе кастомных эмодзи в подсказке нет', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer(null);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
// Юникод-словарь по `pep` пуст, кастомных эмодзи без сервера нет.
await waitFor(() => {
expect(screen.queryByTestId('composer-autocomplete')).toBeNull();
});
});
});
describe('кастомные эмодзи: реакции', () => {
const user = makeUser();
const members = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online' as const,
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: [],
},
];
/** Приложение на маршруте комнаты сервера с кастомными эмодзи в READY. */
async function boot(messages: Message[], extraRoutes: FetchRoute[] = []) {
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ['SEND_MESSAGES', 'ADD_REACTIONS'],
channels: [{ id: 'c-1', name: 'общий' }],
emojis: [pepe, parrot],
},
]);
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({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', messages, extraRoutes),
]);
const gateway = installGatewaySocket(snapshot);
const view = renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...view, fetchMock, gateway };
}
it('рисует чип реакции картинкой и ходит тем же токеном', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[
makeMessage({
id: 'm-1',
channel_id: 'c-1',
author_id: 'user-2',
content: 'реагируй',
reactions: [{ emoji: '<:pepe:900>', count: 1, me: false, user_ids: ['user-2'] }],
}),
],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
const chip = await screen.findByRole('button', { name: 'Реакция :pepe:: 1' });
expect(chip.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.click(chip);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :pepe:: 2' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
// Токен уезжает в пути закодированным (%3C%3Apepe%3A900%3E).
const request = findRequest(fetchMock, { url: '/reactions/', method: 'PUT' });
expect(request?.url).toContain(encodeURIComponent('<:pepe:900>'));
});
it('ставит реакцию кастомным эмодзи из пикера', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-2', content: 'меню' })],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
await screen.findByText('меню');
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
await visitor.click(
within(screen.getByTestId('emoji-picker-server')).getByRole('button', {
name: 'Выбрать эмодзи party_parrot',
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :party_parrot:: 1' })).toBeVisible();
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })?.url).toContain(
encodeURIComponent('<a:party_parrot:901>'),
);
});
});
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
const snapshot = makeReadySnapshot([{ id: 'g-1', name: 'Сервер', emojis: [pepe] }]);
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot });
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]);
dispatchGatewayEvent({
op: 0,
t: 'GUILD_EMOJIS_UPDATE',
s: 2,
d: { guild_id: 'g-1', emojis: [pepe, parrot] },
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe, parrot]);
});
it('разбирает payload события и отбрасывает битые записи', () => {
const parsed = parseGuildEmojisUpdate({
guild_id: 'g-1',
emojis: [{ id: 'e-1', name: 'pepe', file_id: '900', animated: false }],
});
expect(parsed?.guild_id).toBe('g-1');
expect(parsed?.emojis[0]?.token).toBe('<:pepe:900>');
expect(parseGuildEmojisUpdate({ emojis: [] })).toBeNull();
});
it('без события эмодзи сервера остаются пустыми', () => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 1,
d: makeReadySnapshot([{ id: 'g-1', name: 'Сервер' }]),
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([]);
});
});
describe('кастомные эмодзи: беседа без сервера', () => {
it('в личной беседе пикер реакций не показывает эмодзи сервера', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<MessageList
channelId="dm-1"
channelName="Боб"
direct
messages={[
makeMessage({ id: 'm-1', channel_id: 'dm-1', author_id: 'user-2', content: 'привет' }),
]}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages={false}
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
expect(screen.getByText('привет')).toBeVisible();
// Сервер комнаты неизвестен, значит кастомных эмодзи в пикере нет.
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Выбор эмодзи' })).toBeVisible();
});
});