import { describe, expect, it } from 'vitest'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { RelationshipUser, Relationships } from '@/api/friends'; import type { User } from '@/api/types'; import { findRequest, installFetch, installGatewaySocket, json, makeMessage, makeReadySnapshot, makeUser, messagesRoutes, recordedRequests, renderApp, type FetchRoute, } from './helpers'; /** * Раздел «Друзья» и личные беседы: рейка, пустое состояние, поиск, заявки, * статусы, поповер последнего входа, DM-чат, приглашения и события шлюза. */ const user = makeUser(); /** Инстанс с «главным» сервером: автовступления в него больше быть не должно. */ const instance = { instance: { name: 'glchat-test', registration_enabled: true, allow_guild_creation: true, max_guilds_per_user: 5, main_guild_id: 'g-main', }, }; const empty: Relationships = { friends: [], incoming: [], outgoing: [], blocked: [] }; /** Пользователь со связью: значения по умолчанию — «друг в сети». */ function relationship( overrides: Partial & { user_id: string }, ): RelationshipUser { const username = overrides.username ?? overrides.user_id; return { username, display_name: username, status: 'online', visible_status: 'online', is_instance_admin: false, badges: [], timezone: 'Europe/Moscow', relationship: 'friend', ...overrides, }; } const bob = relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' }); /** Базовые ответы оболочки: профиль, инстанс, серверы, связи. */ function baseRoutes( relationships: Relationships | (() => Relationships) = empty, extra: FetchRoute[] = [], ): FetchRoute[] { return [ ...extra, { match: '/api/v1/users/@me/relationships', response: () => json(typeof relationships === 'function' ? relationships() : relationships), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json(instance) }, ]; } /** Личная беседа в REST-форме (`GET /users/@me/channels`). */ function directChannel(id: string, displayName = 'Боб', status = 'online') { return { id, type: 'dm', can_send: true, can_view: true, recipient: { user_id: 'user-2', username: 'bob', display_name: displayName, status, visible_status: status, timezone: 'Europe/Moscow', }, }; } /** * Событие шлюза внутри `act`: обработчик обновляет сторы, а часть работы * (перечитывание списков) завершается промисами. */ async function dispatchEvent( gateway: ReturnType, type: string, payload: unknown, ): Promise { await act(async () => { gateway.dispatch(type, payload); await Promise.resolve(); }); } describe('раздел друзей', () => { it('первой иконкой рейки открывает раздел друзей', async () => { const visitor = userEvent.setup(); const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', channels: [{ id: 'c-1', name: 'общий' }] }, ]); installFetch(baseRoutes(empty, messagesRoutes('c-1', []))); const gateway = installGatewaySocket(snapshot); const { router } = renderApp('/app/g-1/c-1'); await screen.findByTestId('guild-rail'); await gateway.greet(); await screen.findByLabelText('Открыть комнату общий'); // Друзья — выше списка серверов. const friendsIcon = screen.getByTestId('friends-rail-link'); expect(friendsIcon.compareDocumentPosition(screen.getByTestId('guild-rail'))).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); expect(friendsIcon).toHaveAccessibleName('Друзья'); await visitor.click(friendsIcon); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/friends'); }); // Сайдбар раздела: заголовок «Друзья». expect(await screen.findByRole('heading', { level: 1, name: 'Друзья' })).toBeVisible(); expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('aria-current', 'page'); }); it('без друзей и бесед показывает пустое состояние с кнопкой поиска', async () => { installFetch(baseRoutes(empty)); renderApp('/app/friends'); expect(await screen.findByText('Добавь друзей для общения')).toBeVisible(); expect(screen.getByRole('button', { name: 'Найти друзей' })).toBeVisible(); expect(screen.queryByTestId('dm-list')).toBeNull(); expect(screen.getByTestId('dm-sidebar-empty')).toBeVisible(); }); it('ищет пользователей по логину и отправляет заявку в друзья', async () => { const visitor = userEvent.setup(); let requested = false; const fetchMock = installFetch( baseRoutes( () => (requested ? { ...empty, outgoing: [bob] } : empty), [ { match: '/api/v1/users/search', response: () => json({ users: [ relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб', relationship: 'none', }), ], }), }, { match: '/api/v1/users/@me/relationships', method: 'POST', response: () => { requested = true; return json({ ok: true }); }, }, ], ), ); renderApp('/app/friends'); await visitor.click(await screen.findByRole('button', { name: 'Найти друзей' })); const input = await screen.findByLabelText('Поиск по логину'); await visitor.type(input, 'bob'); // Debounce 400 мс: запрос уходит после паузы в наборе. expect(await screen.findByText('Боб', {}, { timeout: 3000 })).toBeVisible(); expect(screen.getByText('@bob')).toBeVisible(); expect(findRequest(fetchMock, { url: '/users/search' })?.url).toContain('q=bob'); await visitor.click(screen.getByRole('button', { name: 'Добавить в друзья' })); expect(await screen.findByText('Заявка отправлена')).toBeVisible(); expect( findRequest(fetchMock, { url: '/users/@me/relationships', method: 'POST' })?.body, ).toEqual({ username: 'bob' }); }); it('принимает входящую заявку и отменяет исходящую', async () => { const visitor = userEvent.setup(); let accepted = false; const fetchMock = installFetch( baseRoutes( () => ({ friends: accepted ? [relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол' })] : [], incoming: accepted ? [] : [ relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол', relationship: 'incoming', }), ], outgoing: [ relationship({ user_id: 'user-4', username: 'dave', display_name: 'Дэйв', relationship: 'outgoing', }), ], blocked: [], }), [ { match: '/api/v1/users/@me/relationships/user-3/accept', method: 'POST', response: () => { accepted = true; return json({ ok: true }); }, }, { match: '/api/v1/users/@me/relationships/user-4', method: 'DELETE', response: () => json({ ok: true }), }, ], ), ); renderApp('/app/friends'); const incoming = await screen.findByTestId('friends-incoming'); expect(incoming).toHaveTextContent('@carol'); await visitor.click(within(incoming).getByRole('button', { name: 'Принять' })); expect( findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }), ).toBeDefined(); expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол'); const outgoing = screen.getByTestId('friends-outgoing'); await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' })); await waitFor(() => { expect( findRequest(fetchMock, { url: '/users/@me/relationships/user-4', method: 'DELETE' }), ).toBeDefined(); }); }); it('отклоняет входящую заявку тем же DELETE', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch( baseRoutes( { ...empty, incoming: [ relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол', relationship: 'incoming', }), ], }, [ { match: '/api/v1/users/@me/relationships/user-3', method: 'DELETE', response: () => json({ ok: true }), }, ], ), ); renderApp('/app/friends'); const incoming = await screen.findByTestId('friends-incoming'); await visitor.click(within(incoming).getByRole('button', { name: 'Отклонить' })); await waitFor(() => { expect( findRequest(fetchMock, { url: '/users/@me/relationships/user-3', method: 'DELETE' }), ).toBeDefined(); }); }); it('рисует цветные точки статусов в списке друзей', async () => { installFetch( baseRoutes({ friends: [ relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' }), relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол', status: 'idle', visible_status: 'idle', }), relationship({ user_id: 'user-4', username: 'dave', display_name: 'Дэйв', status: 'dnd', visible_status: 'dnd', }), relationship({ user_id: 'user-5', username: 'erin', display_name: 'Эрин', status: 'offline', visible_status: 'offline', }), relationship({ user_id: 'user-6', username: 'frank', display_name: 'Фрэнк', status: 'invisible', visible_status: 'invisible', }), ], incoming: [], outgoing: [], blocked: [], }), ); renderApp('/app/friends'); const list = await screen.findByTestId('friends-list'); const dots: [string, string, string][] = [ ['user-2', 'online', 'bg-success'], ['user-3', 'idle', 'bg-warning'], ['user-4', 'dnd', 'bg-fg-muted'], ['user-5', 'offline', 'bg-danger'], ['user-6', 'invisible', 'bg-info'], ]; for (const [id, status, className] of dots) { const dot = within(list).getByTestId(`friend-status-${id}`); expect(dot).toHaveAttribute('data-status', status); expect(dot).toHaveClass(className); } }); it('показывает последний вход в часовом поясе пользователя (МСК по умолчанию)', async () => { const visitor = userEvent.setup(); installFetch( baseRoutes({ friends: [ relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб', status: 'offline', visible_status: 'offline', last_seen_at: '2026-09-19T10:00:00Z', }), relationship({ user_id: 'user-5', username: 'erin', display_name: 'Эрин', status: 'offline', visible_status: 'offline', }), relationship({ user_id: 'user-6', username: 'frank', display_name: 'Фрэнк' }), ], incoming: [], outgoing: [], blocked: [], }), ); renderApp('/app/friends'); await screen.findByTestId('friends-list'); // Наведение на имя: 10:00 UTC — это 13:00 в Москве. await visitor.hover(screen.getByTestId('friend-name-user-2')); expect(await screen.findByText('Был(а) в сети: 19.09.2026, 13:00')).toBeVisible(); await visitor.unhover(screen.getByTestId('friend-name-user-2')); // Подсказка доступна и с клавиатуры: фокус на имени открывает поповер. const erin = screen.getByTestId('friend-name-user-5'); act(() => { erin.focus(); }); expect(await screen.findByText('Не в сети давно')).toBeVisible(); act(() => { erin.blur(); }); // Пользователь в сети — время последнего входа не показываем. await visitor.hover(screen.getByTestId('friend-name-user-6')); expect(await screen.findByText('Сейчас в сети')).toBeVisible(); }); it('переводит время последнего входа в пояс из настроек профиля', async () => { const visitor = userEvent.setup(); const ekbUser: User = makeUser({ timezone: 'Asia/Yekaterinburg' }); installFetch( baseRoutes( { friends: [ relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб', status: 'offline', visible_status: 'offline', last_seen_at: '2026-09-19T10:00:00Z', }), ], incoming: [], outgoing: [], blocked: [], }, // Профиль с поясом из настроек: маршруты из `extra` проверяются раньше. [{ match: '/api/v1/users/@me', response: () => json({ user: ekbUser }) }], ), ); renderApp('/app/friends'); await screen.findByTestId('friends-list'); await visitor.hover(screen.getByTestId('friend-name-user-2')); // 10:00 UTC — 15:00 в Екатеринбурге (UTC+5). expect(await screen.findByText('Был(а) в сети: 19.09.2026, 15:00')).toBeVisible(); }); it('открывает личную беседу и отправляет сообщение', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch( baseRoutes({ ...empty, friends: [bob] }, [ { match: '/api/v1/users/@me/channels', method: 'POST', response: () => json({ channel: directChannel('dm-1') }), }, ...messagesRoutes( 'dm-1', [], [ { match: '/api/v1/channels/dm-1/messages', method: 'POST', response: () => json({ message: makeMessage({ id: 'm-1', channel_id: 'dm-1', author_id: 'user-1', content: 'привет', }), }), }, ], ), ]), ); const { router } = renderApp('/app/friends'); const list = await screen.findByTestId('friends-list'); await visitor.click(within(list).getByRole('button', { name: 'Написать' })); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/friends/dm-1'); }); expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({ recipient_id: 'user-2', }); // Шапка беседы: имя собеседника и точка статуса. expect(await screen.findByRole('heading', { name: 'Боб' })).toBeVisible(); expect(screen.getByTestId('dm-status')).toHaveAttribute('data-status', 'online'); const composer = await screen.findByTestId('composer'); await visitor.type(within(composer).getByRole('textbox'), 'привет'); await visitor.click(within(composer).getByRole('button', { name: 'Отправить' })); await waitFor(() => { expect( findRequest(fetchMock, { url: '/api/v1/channels/dm-1/messages', method: 'POST' })?.body, ).toMatchObject({ content: 'привет' }); }); expect(await screen.findByTestId('message-list')).toHaveTextContent('привет'); // Композер в личной беседе работает без прав сервера. expect(within(composer).getByRole('textbox')).toHaveAttribute('placeholder', 'Написать Боб'); }); it('открывает беседу по прямой ссылке, если её нет в снапшоте', async () => { installFetch( baseRoutes({ ...empty, friends: [bob] }, [ { match: '/api/v1/users/@me/channels', response: () => json({ channels: [directChannel('dm-7', 'Кэрол')] }), }, ...messagesRoutes('dm-7', []), ]), ); renderApp('/app/friends/dm-7'); // Список бесед подтягивается по REST — беседа открывается. expect(await screen.findByRole('heading', { name: 'Кэрол' })).toBeVisible(); expect(await screen.findByTestId('composer')).toBeVisible(); }); it('присоединяется по приглашению: предпросмотр и сервер в рейке', async () => { const visitor = userEvent.setup(); const guildDetail = { id: 'g-inv', name: 'Приглашённые', description: '', owner_id: 'user-9', is_main: false, member_count: 3, roles: [], my_role_ids: [], my_permissions: ['SEND_MESSAGES'], }; const fetchMock = installFetch( baseRoutes(empty, [ { match: '/api/v1/invites/ab12cd', response: () => json({ invite: { code: 'ab12cd', guild_id: 'g-inv' }, guild: { id: 'g-inv', name: 'Приглашённые', member_count: 3, is_member: false }, }), }, { match: '/api/v1/invites/ab12cd', method: 'POST', response: () => json({ guild_id: 'g-inv' }), }, { match: '/api/v1/guilds/g-inv/channels', response: () => json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }), }, { match: '/api/v1/guilds/g-inv/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-inv/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-inv', response: () => json({ guild: guildDetail }) }, ...messagesRoutes('c-1', []), ]), ); const { router } = renderApp('/app/friends'); const input = await screen.findByLabelText('Код или ссылка приглашения'); await visitor.type(input, 'https://gl.localhost/invite/ab12cd'); const preview = await screen.findByTestId('invite-preview', {}, { timeout: 3000 }); expect(preview).toHaveTextContent('Приглашённые'); expect(preview).toHaveTextContent('Участников: 3'); await visitor.click(screen.getByRole('button', { name: 'Присоединиться' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/invites/ab12cd', method: 'POST' })).toBeDefined(); }); // Сервер появился в рейке, открылся его первый канал. await waitFor(() => { expect(router.state.location.pathname).toBe('/app/g-inv/c-1'); }); const rail = screen.getByTestId('guild-rail'); expect(await within(rail).findByLabelText('Открыть сервер Приглашённые')).toBeVisible(); }); it('сообщает о недействительном приглашении', async () => { const visitor = userEvent.setup(); installFetch( baseRoutes(empty, [ { match: '/api/v1/invites/dead', response: () => json({ error: { code: 'invite.expired', message: 'invite has expired' } }, 410), }, ]), ); renderApp('/app/friends'); await visitor.type(await screen.findByLabelText('Код или ссылка приглашения'), 'dead'); expect( await screen.findByText('Срок действия приглашения истёк.', {}, { timeout: 3000 }), ).toBeVisible(); }); it('не вступает ни в какой сервер автоматически', async () => { const fetchMock = installFetch(baseRoutes(empty)); const gateway = installGatewaySocket(makeReadySnapshot([])); renderApp('/app/friends'); await screen.findByTestId('guild-rail'); await gateway.greet(); expect(await screen.findByTestId('friends-empty')).toBeVisible(); expect(screen.getByTestId('guild-rail').children).toHaveLength(0); // «Главного сервера» в рейке больше нет: только приглашение. expect(recordedRequests(fetchMock).some((request) => request.url.includes('/join'))).toBe( false, ); expect(screen.getByTestId('invite-join')).toBeVisible(); }); }); describe('события шлюза о друзьях', () => { it('DM_CHANNEL_CREATE перечитывает список бесед', async () => { let created = false; installFetch( baseRoutes(empty, [ { match: '/api/v1/users/@me/channels', response: () => json({ channels: created ? [directChannel('dm-1')] : [] }), }, ]), ); const gateway = installGatewaySocket(makeReadySnapshot([])); renderApp('/app/friends'); await screen.findByTestId('guild-rail'); await gateway.greet(); expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible(); created = true; await dispatchEvent(gateway, 'DM_CHANNEL_CREATE', { channel_id: 'dm-1' }); expect(await screen.findByTestId('dm-link-dm-1')).toBeVisible(); expect(screen.getByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'online'); }); it('RELATIONSHIP_UPDATE перечитывает друзей', async () => { let hasFriend = false; installFetch(baseRoutes(() => (hasFriend ? { ...empty, friends: [bob] } : empty))); const gateway = installGatewaySocket(makeReadySnapshot([])); renderApp('/app/friends'); await screen.findByTestId('guild-rail'); await gateway.greet(); expect(await screen.findByTestId('friends-empty')).toBeVisible(); hasFriend = true; await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' }); const list = await screen.findByTestId('friends-list'); expect(list).toHaveTextContent('Боб'); }); it('PRESENCE_UPDATE обновляет статус друга без перезагрузки', async () => { const fetchMock = installFetch( baseRoutes({ ...empty, friends: [bob] }, [ { match: '/api/v1/users/@me/channels', response: () => json({ channels: [directChannel('dm-1')] }), }, ]), ); const gateway = installGatewaySocket( makeReadySnapshot( [], [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', status: 'online' }], ), ); renderApp('/app/friends'); const list = await screen.findByTestId('friends-list'); await gateway.greet(); expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute( 'data-status', 'online', ); const before = recordedRequests(fetchMock).filter( (request) => request.url.includes('/users/@me/relationships') && request.method === 'GET', ).length; await dispatchEvent(gateway, 'PRESENCE_UPDATE', { user_id: 'user-2', status: 'dnd', visible_status: 'dnd', custom_status: 'занят', last_seen_at: '2026-09-19T10:00:00Z', }); const dot = await within(list).findByTestId('friend-status-user-2'); expect(dot).toHaveAttribute('data-status', 'dnd'); expect(dot).toHaveClass('bg-fg-muted'); // Статус собеседника обновился и в сайдбаре личных бесед. expect(await screen.findByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'dnd'); // Перезагрузки списка друзей не было: событие применено точечно. const after = recordedRequests(fetchMock).filter( (request) => request.url.includes('/users/@me/relationships') && request.method === 'GET', ).length; expect(after).toBe(before); }); it('MESSAGE_CREATE в беседе обновляет бейдж непрочитанного', async () => { installFetch( baseRoutes({ ...empty, friends: [bob] }, [ { match: '/api/v1/users/@me/channels', response: () => json({ channels: [directChannel('dm-1')] }), }, ]), ); const gateway = installGatewaySocket( makeReadySnapshot( [], [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-1' }], ), ); const { router } = renderApp('/app/friends'); await screen.findByTestId('guild-rail'); await gateway.greet(); // Отметки чтения нет: беседа не читалась — в сайдбаре точка непрочитанного. expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible(); await dispatchEvent(gateway, 'MESSAGE_CREATE', { id: 'm-2', channel_id: 'dm-1', author_id: 'user-2', content: 'эй', created_at: '2026-09-19T10:05:00Z', }); // Новое сообщение сдвигает «последнее» в беседе — точка остаётся. expect(screen.getByTestId('dm-unread-dm-1')).toBeVisible(); expect(router.state.location.pathname).toBe('/app/friends'); }); it('открытая беседа отмечается прочитанной через ack', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch( baseRoutes({ ...empty, friends: [bob] }, [ { match: '/api/v1/users/@me/channels', response: () => json({ channels: [directChannel('dm-1')] }), }, ...messagesRoutes('dm-1', [ makeMessage({ id: 'm-5', channel_id: 'dm-1', author_id: 'user-2', content: 'привет', created_at: '2026-09-19T10:05:00Z', }), ]), ]), ); const gateway = installGatewaySocket( makeReadySnapshot( [], [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-5' }], ), ); renderApp('/app/friends'); await screen.findByTestId('guild-rail'); await gateway.greet(); // Беседа не читалась (последнее сообщение новее отметки) — есть бейдж. expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible(); await visitor.click(screen.getByTestId('dm-link-dm-1')); expect(await screen.findByTestId('message-list')).toHaveTextContent('привет'); // Беседа открыта и окно активно — уходит POST /ack с последним сообщением. await waitFor( () => { expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })).toBeDefined(); }, { timeout: 3000 }, ); expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })?.body).toEqual({ last_message_id: 'm-5', }); }); });