import { describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Relationships } from '@/api/friends'; import { apiError, bodyOf, findRequest, installFetch, installGatewaySocket, json, makeReadySnapshot, makeUser, recordedRequests, renderApp, type FetchRoute, } from './helpers'; /** * Переименование групповой беседы (AGENT.md 7.8, D-075): пункт «Переименовать» * в меню владельца, диалог с текущим именем и `PATCH /channels/{id}`. У * остальных участников имя обновляется событием `DM_CHANNEL_UPDATE` — без * перезагрузки и повторного входа (AGENT.md 11.6). */ 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: [], incoming: [], outgoing: [], blocked: [] }; /** Групповая беседа в REST-форме: владелец настраивается. */ function groupChannel(options: { ownerId?: string; name?: string } = {}) { return { id: 'dm-group-1', type: 'dm', is_group: true, name: options.name ?? 'Игровая', member_count: 3, owner_id: options.ownerId ?? 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: options.ownerId === undefined, 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 dmRoutes(current: () => 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/channels/dm-group-1/messages', response: () => json({ messages: [] }) }, { match: '/api/v1/channels/dm-group-1/pins', response: () => json({ messages: [] }) }, { match: '/api/v1/channels/dm-group-1/ack', method: 'POST', response: () => json({ ok: true }), }, { match: '/api/v1/users/@me/channels', response: () => json({ channels: current() }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json(instance) }, ]; } /** Открывает беседу и ждёт её отрисовки в сайдбаре. */ async function openGroup() { renderApp('/app/friends/dm-group-1'); const gateway = installGatewaySocket(makeReadySnapshot()); await gateway.greet(); await screen.findByTestId('dm-group-header'); return gateway; } /** Открывает диалог переименования из меню владельца. */ async function openRenameDialog() { await userEvent.click(await screen.findByTestId('dm-owner-menu')); await userEvent.click(await screen.findByTestId('dm-group-rename')); return screen.findByRole('dialog', { name: 'Переименовать беседу' }); } describe('переименование групповой беседы', () => { it('владелец переименовывает беседу: имя меняется в шапке и в списке', async () => { let channels = [groupChannel()]; const fetchMock = installFetch( dmRoutes( () => channels, [ { match: '/api/v1/channels/dm-group-1', method: 'PATCH', response: () => { const updated = groupChannel({ name: 'Кино по пятницам' }); channels = [updated]; return json({ channel: updated }); }, }, ], ), ); await openGroup(); const dialog = await openRenameDialog(); // Поле уже заполнено текущим именем: переименование начинается с правки. const field = within(dialog).getByLabelText('Название группы'); expect(field).toHaveValue('Игровая'); await userEvent.clear(field); await userEvent.type(field, 'Кино по пятницам'); await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); await waitFor(() => { expect( bodyOf(findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' })), ).toEqual({ name: 'Кино по пятницам' }); }); // Диалог закрылся, а новое имя видно и в шапке, и в списке бесед. await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Переименовать беседу' })).toBeNull(); }); expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Кино по пятницам'); expect(await screen.findByTestId('dm-link-dm-group-1')).toHaveTextContent('Кино по пятницам'); }); it('пустое имя отправить нельзя: кнопка сохранения заблокирована', async () => { const fetchMock = installFetch(dmRoutes(() => [groupChannel()])); await openGroup(); const dialog = await openRenameDialog(); await userEvent.clear(within(dialog).getByLabelText('Название группы')); expect(within(dialog).getByRole('button', { name: 'Сохранить' })).toBeDisabled(); // Пробелы именем не считаются: сервер обрезает их и отвечает отказом. await userEvent.type(within(dialog).getByLabelText('Название группы'), ' '); expect(within(dialog).getByRole('button', { name: 'Сохранить' })).toBeDisabled(); expect( findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' }), ).toBeUndefined(); }); it('отмена закрывает диалог без запроса и не запоминает черновик', async () => { const fetchMock = installFetch(dmRoutes(() => [groupChannel()])); await openGroup(); const dialog = await openRenameDialog(); await userEvent.clear(within(dialog).getByLabelText('Название группы')); await userEvent.type(within(dialog).getByLabelText('Название группы'), 'Черновик'); await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Переименовать беседу' })).toBeNull(); }); expect( findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' }), ).toBeUndefined(); expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Игровая'); // Второе открытие снова показывает текущее имя сервера, а не черновик. const reopened = await openRenameDialog(); expect(within(reopened).getByLabelText('Название группы')).toHaveValue('Игровая'); }); it('отказ сервера показывается в диалоге, имя остаётся прежним', async () => { installFetch( dmRoutes( () => [groupChannel()], [ { match: '/api/v1/channels/dm-group-1', method: 'PATCH', response: () => apiError('validation.failed', 422, 'group name must be 1-64 characters'), }, ], ), ); await openGroup(); const dialog = await openRenameDialog(); const field = within(dialog).getByLabelText('Название группы'); await userEvent.clear(field); await userEvent.type(field, 'Слишком длинное имя'); await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); // Окно не закрывается: пользователь видит причину и может исправить имя. expect(await within(dialog).findByRole('alert')).toBeVisible(); expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Игровая'); }); it('участнику-не-владельцу пункт переименования недоступен', async () => { installFetch(dmRoutes(() => [groupChannel({ ownerId: 'user-2' })])); await openGroup(); expect(screen.queryByTestId('dm-group-rename')).toBeNull(); expect(screen.queryByTestId('dm-owner-menu')).toBeNull(); }); it('событие DM_CHANNEL_UPDATE доносит новое имя без перезагрузки', async () => { let channels = [groupChannel()]; const fetchMock = installFetch(dmRoutes(() => channels)); const gateway = await openGroup(); // Беседу переименовал владелец (возможно, с другого устройства): сервер // сообщает об изменении события, а список перечитывается по REST. channels = [groupChannel({ name: 'Новое имя' })]; gateway.dispatch('DM_CHANNEL_UPDATE', { channel_id: 'dm-group-1' }); await waitFor(() => { expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Новое имя'); }); expect(await screen.findByTestId('dm-link-dm-group-1')).toHaveTextContent('Новое имя'); // Имя пришло событием: своих PATCH-запросов тест не делал. expect(recordedRequests(fetchMock).filter((request) => request.method === 'PATCH')).toEqual([]); }); });