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', }), 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( 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('Кэрол присоединился к серверу'); }); }); });