import { describe, expect, it } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Relationships } from '@/api/friends'; import { findRequest, installFetch, installGatewaySocket, json, makeReadySnapshot, makeUser, renderApp, type FetchRoute, } from './helpers'; /** * Групповые личные беседы (AGENT.md 7.8, Фаза 7): отображение в сайдбаре, * создание из списка друзей и шапка беседы с числом участников. */ 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 friends: Relationships = { friends: [ { user_id: 'user-2', username: 'bob', display_name: 'Боб', status: 'online', visible_status: 'online', is_instance_admin: false, badges: [], timezone: 'Europe/Moscow', relationship: 'friend', }, { user_id: 'user-3', username: 'carol', display_name: 'Кэрол', status: 'offline', visible_status: 'offline', is_instance_admin: false, badges: [], timezone: 'Europe/Moscow', relationship: 'friend', }, ], incoming: [], outgoing: [], blocked: [], }; /** Групповая беседа в REST-форме (`GET /users/@me/channels`). */ function groupChannel(id = 'dm-group-1', memberCount = 3) { return { id, type: 'dm', is_group: true, name: 'Игровая', member_count: memberCount, owner_id: user.id, can_send: true, can_view: true, recipients: [ { user_id: user.id, username: 'alice', display_name: 'Alice', status: 'online', visible_status: 'online', is_owner: true, is_self: true, }, { user_id: 'user-2', username: 'bob', display_name: 'Боб', status: 'online', visible_status: 'online', is_owner: false, is_self: false, }, { user_id: 'user-3', username: 'carol', display_name: 'Кэрол', status: 'offline', visible_status: 'offline', is_owner: false, is_self: false, }, ], }; } function baseRoutes(channels: unknown[], extra: FetchRoute[] = []): FetchRoute[] { return [ ...extra, { match: '/api/v1/users/@me/relationships', response: () => json(friends) }, { 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) }, ]; } describe('групповые беседы', () => { it('группа показана в сайдбаре с числом участников и без точки статуса', async () => { const channel = groupChannel(); installFetch( baseRoutes( [channel], [ { match: `/api/v1/channels/${channel.id}/messages`, response: () => json({ messages: [] }), }, { match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) }, { match: `/api/v1/channels/${channel.id}/ack`, method: 'POST', response: () => json({ ok: true }), }, ], ), ); const gateway = installGatewaySocket(makeReadySnapshot()); // Сайдбар раздела «Друзья» живёт в оболочке приложения: открываем беседу, // чтобы он отрисовался (как в реальном переходе). renderApp(`/app/friends/${channel.id}`); await gateway.greet(); const link = await screen.findByTestId('dm-link-dm-group-1'); expect(link).toHaveTextContent('Игровая'); expect(screen.getByTestId('dm-members-dm-group-1')).toHaveTextContent('3'); // У группы нет статуса собеседника: точки рядом с аватаром быть не должно. expect(screen.queryByTestId('dm-status-dm-group-1')).toBeNull(); }); it('шапка групповой беседы показывает число участников', async () => { const channel = groupChannel(); installFetch( baseRoutes( [channel], [ { match: `/api/v1/channels/${channel.id}/messages`, response: () => json({ messages: [] }), }, { match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) }, { match: `/api/v1/channels/${channel.id}/ack`, method: 'POST', response: () => json({ ok: true }), }, ], ), ); const gateway = installGatewaySocket(makeReadySnapshot()); renderApp(`/app/friends/${channel.id}`); await gateway.greet(); expect(await screen.findByTestId('dm-group-header')).toBeVisible(); expect(screen.getByTestId('dm-member-count')).toHaveTextContent('Участников: 3'); }); it('создаёт группу из двух друзей и открывает её', async () => { const created = groupChannel('dm-group-2'); const fetchMock = installFetch( baseRoutes( [], [ { match: '/api/v1/users/@me/channels/group', method: 'POST', response: () => json({ channel: created }), }, ], ), ); const gateway = installGatewaySocket(makeReadySnapshot()); const visitor = userEvent.setup(); const { router } = renderApp('/app/friends'); await gateway.greet(); await screen.findByTestId('dm-sidebar-empty'); await visitor.click(await screen.findByTestId('group-dm-open')); await visitor.type(screen.getByLabelText('Название группы'), 'Игровая'); await visitor.click(screen.getByRole('checkbox', { name: 'Боб' })); await visitor.click(screen.getByRole('checkbox', { name: 'Кэрол' })); const submit = screen.getByRole('button', { name: 'Создать' }); // С одним участником группа не создаётся: кнопка включается со второго. expect(submit).toBeEnabled(); await visitor.click(submit); await waitFor(() => { expect(findRequest(fetchMock, { url: '/channels/group', method: 'POST' })).toBeDefined(); }); const request = findRequest(fetchMock, { url: '/channels/group', method: 'POST' }); expect(request?.body).toEqual({ name: 'Игровая', recipient_ids: ['user-2', 'user-3'], }); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/friends/dm-group-2'); }); // Беседа сразу попала в список — отдельный запрос не нужен. expect(await screen.findByTestId('dm-link-dm-group-2')).toBeVisible(); }); it('без двух выбранных друзей кнопка создания выключена', async () => { installFetch(baseRoutes([])); const gateway = installGatewaySocket(makeReadySnapshot()); const visitor = userEvent.setup(); renderApp('/app/friends'); await gateway.greet(); await visitor.click(await screen.findByTestId('group-dm-open')); await visitor.click(screen.getByRole('checkbox', { name: 'Боб' })); expect(screen.getByRole('button', { name: 'Создать' })).toBeDisabled(); }); });