2026-09-20 18:13:04 +03:00
|
|
|
|
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';
|
2026-09-20 18:19:19 +03:00
|
|
|
|
import {
|
|
|
|
|
|
makeMessage,
|
|
|
|
|
|
makeUser,
|
|
|
|
|
|
json,
|
|
|
|
|
|
installFetch,
|
|
|
|
|
|
installGatewaySocket,
|
|
|
|
|
|
messagesRoutes,
|
|
|
|
|
|
renderApp,
|
|
|
|
|
|
resetStores,
|
|
|
|
|
|
} from './helpers';
|
2026-09-20 18:13:04 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Системные сообщения (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,
|
2026-09-21 18:10:39 +03:00
|
|
|
|
isOwner: false,
|
2026-09-20 18:13:04 +03:00
|
|
|
|
isMember: userId === 'user-2',
|
|
|
|
|
|
}),
|
2026-09-21 00:26:50 +03:00
|
|
|
|
resolveWebhook: () => ({
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
name: 'вебхук',
|
|
|
|
|
|
avatarFileId: undefined,
|
|
|
|
|
|
colorHex: null,
|
|
|
|
|
|
isInstanceAdmin: false,
|
2026-09-21 18:10:39 +03:00
|
|
|
|
isOwner: false,
|
2026-09-21 00:26:50 +03:00
|
|
|
|
isMember: false,
|
|
|
|
|
|
}),
|
2026-09-20 18:13:04 +03:00
|
|
|
|
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"}')]);
|
|
|
|
|
|
|
2026-09-20 18:19:19 +03:00
|
|
|
|
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Боб покинул сервер (исключён)');
|
2026-09-20 18:13:04 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-20 18:19:19 +03:00
|
|
|
|
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
|
|
|
|
|
|
'{"event":"guild_boost","level":2}',
|
|
|
|
|
|
);
|
2026-09-20 18:13:04 +03:00
|
|
|
|
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('Кэрол присоединился к серверу');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|