Files
glchat/web/tests/systemMessages.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

240 lines
8.4 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 { beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import type { Message } from '@/api/messages';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { parseSystemMessage, systemMessageUserId } from '@/lib/systemMessage';
import {
makeMessage,
makeUser,
json,
installFetch,
installGatewaySocket,
messagesRoutes,
renderApp,
resetStores,
} from './helpers';
/**
* Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`,
* а в `content` — JSON о событии. Лента показывает человекочитаемую строку
* с именем участника, а неизвестное событие и мусорный JSON — как есть.
*/
const authors: AuthorDirectory = {
resolve: (userId) => ({
id: userId ?? '',
name: userId === 'user-2' ? 'Боб' : 'неизвестный',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: userId === 'user-2',
}),
resolveWebhook: () => ({
id: '',
name: 'вебхук',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
loaded: true,
};
function systemMessage(content: string, id = 'm-1'): Message {
return makeMessage({
id,
channel_id: 'c-1',
content,
type: 'system',
});
}
function renderList(messages: Message[]) {
return render(
<MessageList
channelId="c-1"
channelName="общий"
messages={messages}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
}
beforeEach(() => {
resetStores();
});
describe('системные сообщения: разбор payload', () => {
it('читает события участника и комнаты', () => {
expect(parseSystemMessage('{"event":"member_join","user_id":"123"}')).toEqual({
kind: 'member_join',
userId: '123',
});
expect(parseSystemMessage('{"event":"member_kick","user_id":"123"}')).toEqual({
kind: 'member_kick',
userId: '123',
});
expect(parseSystemMessage('{"event":"channel_created","name":"общий","type":"text"}')).toEqual({
kind: 'channel_created',
name: 'общий',
channelType: 'text',
});
expect(systemMessageUserId('{"event":"member_kick","user_id":"123"}')).toBe('123');
expect(systemMessageUserId('{"event":"channel_created","name":"общий"}')).toBeNull();
});
it('не падает на мусоре и неизвестном событии', () => {
expect(parseSystemMessage('Аля вошла')).toBeNull();
expect(parseSystemMessage('{"event":"guild_boost"}')).toBeNull();
expect(parseSystemMessage('{"event":"member_join"}')).toBeNull();
expect(parseSystemMessage('"строка"')).toBeNull();
expect(parseSystemMessage('null')).toBeNull();
});
});
describe('системные сообщения: лента', () => {
it('member_join — «{имя} присоединился к серверу»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveTextContent('Боб присоединился к серверу');
// Сырой JSON в ленте не показываем.
expect(body.textContent).not.toContain('member_join');
});
it('member_kick — «{имя} покинул сервер (исключён)»', () => {
renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Боб покинул сервер (исключён)');
});
it('channel_created — комната и голосовая комната', () => {
renderList([
systemMessage('{"event":"channel_created","name":"общий","type":"text"}', 'm-1'),
systemMessage('{"event":"channel_created","name":"Голосовая","type":"voice"}', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Комната #общий создана');
expect(screen.getByTestId('message-m-2')).toHaveTextContent(
'Голосовая комната Голосовая создана',
);
});
it('неизвестное событие и невалидный JSON показываются как есть', () => {
renderList([
systemMessage('{"event":"guild_boost","level":2}', 'm-1'),
systemMessage('Аля вошла', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
'{"event":"guild_boost","level":2}',
);
expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла');
});
it('неизвестный участник подписан «участник»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-9"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('участник присоединился к серверу');
});
it('остаётся центрированной строкой без аватара и действий', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveClass('items-center', 'text-center');
expect(body).toHaveAttribute('data-message-type', 'system');
expect(within(body).queryByRole('button')).toBeNull();
expect(screen.getByTestId('message-group').querySelector('.inline-flex.shrink-0')).toBeNull();
});
});
describe('системные сообщения: имя через справочник авторов', () => {
it('добирает имя участника по GET /users/{id}', async () => {
const user = makeUser();
const snapshot = {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: ['SEND_MESSAGES'],
emojis: [],
},
],
dm_channels: [],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
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' } }) },
// Участника нет в списке сервера — имя приходит из GET /users/{id}.
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{
match: '/api/v1/users/user-9',
response: () =>
json({
user: {
id: 'user-9',
username: 'carol',
display_name: 'Кэрол',
is_instance_admin: false,
badges: [],
},
}),
},
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', [
makeMessage({
id: 'm-1',
channel_id: 'c-1',
type: 'system',
content: '{"event":"member_join","user_id":"user-9"}',
}),
]),
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await waitFor(() => {
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Кэрол присоединился к серверу');
});
});
});