Files
glchat/web/tests/dmRename.test.tsx
grendervill c0137007ce feat(web): переименование групповой беседы кнопкой (Фаза 7)
Пункт «Переименовать» в меню владельца в шапке групповой беседы и диалог с
текущим именем: `PATCH /channels/{id}` (D-075) уже был, интерфейса не было.
Ответ сервера кладём в стор бесед — имя меняется и в шапке, и в списке; при
отказе окно остаётся открытым с причиной, пустое имя отправить нельзя.
Меню владельца было меню иконки: теперь это общее меню беседы (⋯) с
переименованием, загрузкой и снятием иконки — прежние testid иконки смотрят
на те же пункты, поэтому обновлены только ссылки на само меню.

i18n ru/en, Vitest: `web/tests/dmRename.test.tsx` (переименование, пустое имя,
отмена и черновик, отказ сервера, права участника, DM_CHANNEL_UPDATE без
перезагрузки) и обновлённые ссылки в `web/tests/dmIcon.test.tsx`.
2026-09-26 17:33:06 +03:00

255 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});