Files
glchat/web/tests/settings.test.tsx
T
grendervill 4c8f888b3e feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID
Правки пользователя:

- в «Профиль → Основной» сверху квадратный аватар слева, справа от него
  настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара —
  клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке;
- убраны дубли: часовой пояс только в «Язык и регион», статус — только в
  мини-профиле панели;
- панель пользователя переехала в нижний левый угол под сайдбар, подсказка о
  выходе из сервера — на её прежнее место; панель ужата под 240 px;
- шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов,
  общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только
  для ника и «Применить ко всем серверам»;
- окно настроек получило статический размер 70% рабочей области со скроллом;
- ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора;
- список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх;
- 249 тестов Vitest (было 239), check/lint/test/build зелёные.
2026-09-20 11:24:09 +03:00

731 lines
29 KiB
TypeScript
Raw 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, vi } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
requestUrl,
type FetchRoute,
} from './helpers';
const user = makeUser();
const sessions = [
{
id: 's-1',
user_agent: 'Firefox on Linux',
ip: '10.0.0.2',
created_at: '2026-09-01T10:00:00Z',
last_seen: '2026-09-19T10:00:00Z',
current: true,
},
{
id: 's-2',
user_agent: 'Chrome on macOS',
ip: '10.0.0.3',
created_at: '2026-09-02T10:00:00Z',
last_seen: '2026-09-18T10:00:00Z',
current: false,
},
];
/**
* Ответы оболочки приложения: профиль, серверы, инстанс. Дополнительные
* маршруты идут первыми — они должны побеждать при одинаковом шаблоне.
*/
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
return [
...extra,
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
];
}
/** Сводка сервера для ответов `GET /guilds/{id}`. */
function guildDetail(id: string, name: string, ownerId: string, permissions: string[]) {
return {
id,
name,
description: '',
owner_id: ownerId,
is_main: false,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: permissions,
};
}
/** Серверы пользователя: «Альфа» своя, «Бета» с правом MANAGE_GUILD. */
function guildRoute(): FetchRoute[] {
const alpha = guildDetail('g-1', 'Альфа', 'user-1', []);
const beta = guildDetail('g-2', 'Бета', 'user-9', ['MANAGE_GUILD']);
return [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: alpha }) },
{ match: '/api/v1/guilds/g-2', response: () => json({ guild: beta }) },
];
}
/** Открывает окно настроек шестерёнкой в панели пользователя. */
async function openSettings(): Promise<HTMLElement> {
const gear = await screen.findByLabelText('Настройки пользователя');
await userEvent.click(gear);
return screen.findByRole('dialog', { name: 'Настройки' });
}
/** Список пунктов окна настроек. */
function settingsNav(dialog: HTMLElement): HTMLElement {
return within(dialog).getByTestId('settings-nav');
}
/** Открывает пункт «Сервер» (администрирование) в левом меню настроек. */
async function openServerSection(dialog: HTMLElement): Promise<void> {
await userEvent.click(await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }));
}
/** Переходит к пункту настроек по его названию в левом меню. */
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
await userEvent.click(within(dialog).getByRole('button', { name }));
}
/** Тело multipart-запроса аватара (или null, если запроса не было). */
function avatarForm(fetchMock: ReturnType<typeof installFetch>, method: string): FormData | null {
const call = fetchMock.mock.calls.find(
([input, init]) =>
requestUrl(input).includes('/users/@me/avatar') &&
(init?.method ?? 'GET').toUpperCase() === method,
);
const body = call?.[1]?.body;
return body instanceof FormData ? body : null;
}
describe('настройки: окно и навигация', () => {
it('открывается шестерёнкой, показывает пункты и закрывается по Escape, крестику и подложке', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
for (const item of [
'Профиль',
'Безопасность',
'Внешний вид',
'Уведомления',
'Аудио-видео',
'Язык и регион',
]) {
expect(within(dialog).getByRole('button', { name: item })).toBeVisible();
}
expect(within(dialog).getByRole('button', { name: 'Профиль' })).toHaveAttribute(
'aria-current',
'true',
);
// Escape.
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
});
// Крестик.
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
const reopened = await screen.findByRole('dialog', { name: 'Настройки' });
await userEvent.click(within(reopened).getByRole('button', { name: 'Закрыть' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
});
// Клик по подложке.
const gear = await screen.findByLabelText('Настройки пользователя');
await userEvent.click(gear);
await screen.findByRole('dialog', { name: 'Настройки' });
fireEvent.mouseDown(screen.getByTestId('modal-backdrop'));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
});
});
it('переключает вкладки «Основной» и «Сервер» внутри профиля', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
const mainTab = within(dialog).getByRole('tab', { name: 'Основной' });
const serverTab = within(dialog).getByRole('tab', { name: 'Сервер' });
expect(mainTab).toHaveAttribute('aria-selected', 'true');
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
await userEvent.click(serverTab);
expect(serverTab).toHaveAttribute('aria-selected', 'true');
expect(
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
).toBeVisible();
await userEvent.click(within(dialog).getByRole('tab', { name: 'Основной' }));
expect(mainTab).toHaveAttribute('aria-selected', 'true');
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
});
it('пункт «Сервер» видят только те, кто может управлять сервером', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
// Ни своего сервера, ни прав администратора инстанса — пункта нет.
expect(within(settingsNav(dialog)).queryByRole('button', { name: 'Сервер' })).toBeNull();
});
it('владелец сервера открывает администрирование этого сервера', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/app/empty');
const dialog = await openSettings();
// Пункт появляется, когда снапшот сессии узнал серверы пользователя.
await openServerSection(dialog);
// Выбран сервер пользователя: его общие настройки и «Опасная зона».
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Альфа');
expect(within(dialog).getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
});
it('администратор инстанса видит пункт «Сервер» без своих серверов', async () => {
installFetch(appRoutes(makeUser({ is_instance_admin: true })));
renderApp('/app/empty');
const dialog = await openSettings();
expect(
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
).toBeVisible();
});
it('пункт «Инстанс» видят только администраторы', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
});
it('«Уведомления» пока заглушка', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Уведомления');
expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible();
});
it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Аудио-видео');
expect(within(dialog).getAllByText('Устройства недоступны').length).toBeGreaterThan(0);
expect(within(dialog).getByLabelText('Громкость микрофона')).toBeVisible();
expect(within(dialog).getByLabelText('Громкость звука')).toBeVisible();
});
it('«Язык и регион» сохраняет часовой пояс через PATCH /users/@me', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, timezone: 'Europe/Berlin' } }),
},
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Язык и регион');
await userEvent.selectOptions(within(dialog).getByLabelText('Часовой пояс'), 'Europe/Berlin');
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
timezone: 'Europe/Berlin',
});
});
expect(await within(dialog).findByText('Настройки региона сохранены.')).toBeVisible();
});
});
describe('настройки: старые адреса', () => {
it.each([
['/settings', 'Профиль', 'Основной'],
['/settings/account/profile', 'Профиль', 'Основной'],
['/settings/profile', 'Профиль', 'Основной'],
])('%s открывает окно на «%s → %s»', async (from, section, tab) => {
installFetch(appRoutes());
const { router } = renderApp(from);
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
await waitFor(() => {
expect(router.state.location.pathname).toContain('/app');
});
expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute(
'aria-current',
'true',
);
expect(within(dialog).getByRole('tab', { name: tab })).toHaveAttribute('aria-selected', 'true');
});
it.each([
['/settings/security', 'Безопасность'],
['/settings/account/security', 'Безопасность'],
['/settings/appearance', 'Внешний вид'],
['/settings/account/appearance', 'Внешний вид'],
])('%s открывает окно на пункте «%s»', async (from, section) => {
installFetch(appRoutes());
renderApp(from);
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute(
'aria-current',
'true',
);
});
it('/settings/servers открывает пункт «Сервер» администрирования', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/settings/servers');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
).toHaveAttribute('aria-current', 'true');
// Открылся первый сервер списка.
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
});
it('/settings/servers/g-2 открывает администрирование выбранного сервера', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/settings/servers/g-2');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Бета');
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Бета');
});
});
describe('настройки: аватар', () => {
it('загружает файл через POST /users/@me/avatar и удаляет его', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me/avatar',
method: 'POST',
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
},
{
match: '/api/v1/users/@me/avatar',
method: 'DELETE',
response: () => json({ user: { ...user } }),
},
]),
);
renderApp('/app/empty');
await openSettings();
const input = await screen.findByLabelText('Выбрать файл аватара');
const file = new File(['картинка'], 'avatar.png', { type: 'image/png' });
await userEvent.upload(input, file);
await waitFor(() => {
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
});
// Multipart с полем `file`, как ждёт сервер.
const form = avatarForm(fetchMock, 'POST');
expect((form?.get('file') as File | null)?.name).toBe('avatar.png');
// Ответ сервера обновил профиль: аватар отдаётся файловым сервисом.
await waitFor(() => {
expect(document.querySelector('img[src="/files/file-9"]')).not.toBeNull();
});
await userEvent.click(screen.getByRole('button', { name: 'Удалить аватар' }));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/users/@me/avatar') && request.method === 'DELETE',
),
).toBe(true);
});
});
it('слишком большой файл не отправляется, показывается ошибка', async () => {
const fetchMock = installFetch(appRoutes());
renderApp('/app/empty');
await openSettings();
const input = await screen.findByLabelText('Выбрать файл аватара');
const big = new File(['x'], 'big.png', { type: 'image/png' });
Object.defineProperty(big, 'size', { value: 9 * 1024 * 1024 });
await userEvent.upload(input, big);
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше');
expect(avatarForm(fetchMock, 'POST')).toBeNull();
});
it('ошибка сервера file.too_large показывается понятным текстом', async () => {
installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me/avatar',
method: 'POST',
response: () => apiError('file.too_large', 413),
},
]),
);
renderApp('/app/empty');
await openSettings();
const input = await screen.findByLabelText('Выбрать файл аватара');
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
});
it('профиль сохраняется без часового пояса и статуса: у них свои места', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user }),
},
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
expect(within(dialog).queryByLabelText('Статус присутствия')).toBeNull();
// ID не показываем и не копируем в пользовательских настройках.
expect(within(dialog).queryByText('ID пользователя')).toBeNull();
expect(within(dialog).queryByRole('button', { name: 'Скопировать ID' })).toBeNull();
expect(within(dialog).getByLabelText('Отображаемое имя')).toHaveValue('Alice');
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
display_name: 'Alice',
bio: '',
custom_status: '',
custom_status_emoji: '',
});
});
});
it('часовой пояс есть ровно в одном пункте — «Язык и регион»', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
for (const item of ['Профиль', 'Внешний вид', 'Уведомления', 'Аудио-видео']) {
await goToSection(dialog, item);
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
}
await goToSection(dialog, 'Язык и регион');
expect(within(dialog).getAllByLabelText('Часовой пояс')).toHaveLength(1);
});
it('аватар стоит слева от полей, клик по нему открывает выбор файла', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me/avatar',
method: 'POST',
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
},
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
// Аватар — слева: кнопка смены идёт в DOM раньше поля имени.
const avatar = within(dialog).getByTestId('profile-avatar');
const displayName = within(dialog).getByLabelText('Отображаемое имя');
expect(avatar.compareDocumentPosition(displayName)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// Подсказка о форматах — в title и доступном имени кнопки, а не текстом.
const change = within(dialog).getByRole('button', { name: /Сменить аватар/ });
expect(change).toHaveAttribute('title', expect.stringContaining('JPEG'));
expect(within(dialog).queryByText(/PNG, JPEG, WebP/)).toBeNull();
// Клик по аватару открывает выбор файла: он же отправляет multipart.
const input = within(dialog).getByLabelText('Выбрать файл аватара');
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
expect(change).toBeVisible();
expect(document.querySelector('input[type="file"]')).not.toBeNull();
await waitFor(() => {
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
});
});
it('размер окна настроек фиксирован и не зависит от содержимого', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
const panel = dialog;
const style = panel.getAttribute('style') ?? '';
expect(style).toContain('clamp(20rem, 70vw, 72rem)');
expect(style).toContain('clamp(22rem, 70vh, 52rem)');
expect(panel).toHaveClass('overflow-hidden');
expect(screen.getByTestId('settings-content')).toHaveClass('overflow-y-auto');
// Переключение пункта не меняет размеры окна.
const before = panel.getAttribute('style');
await goToSection(dialog, 'Безопасность');
expect(panel.getAttribute('style')).toBe(before);
});
it('ID пользователя в «Инстансе» виден администратору', async () => {
installFetch(
appRoutes(makeUser({ is_instance_admin: true }), [
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-42',
username: 'alice',
display_name: 'Alice',
is_instance_admin: true,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Инстанс');
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
});
});
describe('настройки: безопасность', () => {
it('включение 2FA показывает секрет и коды восстановления', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/auth/2fa/setup',
method: 'POST',
response: () =>
json({
secret: 'JBSWY3DPEHPK3PXP',
otpauth_url: 'otpauth://totp/glchat:alice?secret=X',
// QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны.
qr_png: 'data:image/png;base64,iVBORw0KGgo=',
}),
},
{
match: '/api/v1/auth/2fa/enable',
method: 'POST',
response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }),
},
]);
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP');
expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible();
await visitor.type(screen.getByLabelText('Код из приложения'), '123456');
await visitor.click(screen.getByRole('button', { name: 'Включить' }));
const codes = await screen.findByTestId('recovery-codes');
expect(codes).toHaveTextContent('aaaa-bbbb');
expect(codes).toHaveTextContent('cccc-dddd');
expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({
code: '123456',
});
});
it('logout-all вызывается по кнопке и уводит на /login', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/account/security');
const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(confirm).toHaveBeenCalled();
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout-all') && request.method === 'POST',
),
).toBe(true);
});
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
let passwordAttempts = 0;
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/users/@me/password',
method: 'POST',
response: () => {
passwordAttempts += 1;
return passwordAttempts === 1
? apiError('auth.step_up_required', 403)
: json({ ok: true });
},
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1');
await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1');
await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' }));
// Сервер ответил auth.step_up_required — появилась форма подтверждения.
expect(await screen.findByText('Подтвердите личность')).toBeVisible();
await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1');
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
});
it('профиль сохраняется через PATCH /users/@me', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
},
]);
renderApp('/settings/account/profile');
const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя');
await visitor.clear(displayName);
await visitor.type(displayName, 'Алиса');
await visitor.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' });
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
});
it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
{
match: '/api/v1/instance/settings',
response: () => json({ settings: { motd: 'привет' } }),
},
{
match: '/api/v1/instance/guilds',
response: () =>
json({
guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }],
}),
},
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: true,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{
match: '/api/v1/instance/audit',
response: () =>
json({
entries: [
{
id: 'a-1',
actor_id: 'u-1',
action: 'guild.create',
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
]);
renderApp('/settings/account/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
expect(screen.getByText('motd')).toBeVisible();
});
});