2026-09-19 22:03:46 +03:00
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
2026-09-20 01:32:11 +03:00
|
|
|
|
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
2026-09-19 22:03:46 +03:00
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
apiError,
|
|
|
|
|
|
findRequest,
|
|
|
|
|
|
installFetch,
|
|
|
|
|
|
json,
|
|
|
|
|
|
makeUser,
|
|
|
|
|
|
recordedRequests,
|
|
|
|
|
|
renderApp,
|
2026-09-20 01:32:11 +03:00
|
|
|
|
requestUrl,
|
|
|
|
|
|
type FetchRoute,
|
2026-09-19 22:03:46 +03:00
|
|
|
|
} 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Ответы оболочки приложения: профиль, серверы, инстанс. Дополнительные
|
|
|
|
|
|
* маршруты идут первыми — они должны побеждать при одинаковом шаблоне.
|
|
|
|
|
|
*/
|
2026-09-20 01:32:11 +03:00
|
|
|
|
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
|
|
|
|
|
|
return [
|
2026-09-20 11:24:09 +03:00
|
|
|
|
...extra,
|
2026-09-20 01:32:11 +03:00
|
|
|
|
{ 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' } }) },
|
2026-09-20 11:24:09 +03:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Сводка сервера для ответов `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 }) },
|
2026-09-20 01:32:11 +03:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Открывает окно настроек шестерёнкой в панели пользователя. */
|
|
|
|
|
|
async function openSettings(): Promise<HTMLElement> {
|
|
|
|
|
|
const gear = await screen.findByLabelText('Настройки пользователя');
|
|
|
|
|
|
await userEvent.click(gear);
|
|
|
|
|
|
return screen.findByRole('dialog', { name: 'Настройки' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
/** Список пунктов окна настроек. */
|
|
|
|
|
|
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: 'Сервер' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
/** Переходит к пункту настроек по его названию в левом меню. */
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
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',
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
it('/settings/servers открывает пункт «Сервер» администрирования', async () => {
|
|
|
|
|
|
installFetch(appRoutes(user, guildRoute()));
|
2026-09-20 01:32:11 +03:00
|
|
|
|
|
|
|
|
|
|
renderApp('/settings/servers');
|
|
|
|
|
|
|
|
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
2026-09-20 11:24:09 +03:00
|
|
|
|
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('Бета');
|
2026-09-20 01:32:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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('Файл больше допустимого размера');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
it('профиль сохраняется без часового пояса и статуса: у них свои места', async () => {
|
2026-09-20 01:32:11 +03:00
|
|
|
|
const fetchMock = installFetch(
|
|
|
|
|
|
appRoutes(user, [
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/users/@me',
|
|
|
|
|
|
method: 'PATCH',
|
2026-09-20 11:24:09 +03:00
|
|
|
|
response: () => json({ user }),
|
2026-09-20 01:32:11 +03:00
|
|
|
|
},
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
renderApp('/app/empty');
|
2026-09-20 11:24:09 +03:00
|
|
|
|
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');
|
2026-09-20 01:32:11 +03:00
|
|
|
|
|
2026-09-20 11:24:09 +03:00
|
|
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
2026-09-20 01:32:11 +03:00
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-09-20 11:24:09 +03:00
|
|
|
|
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
|
|
|
|
|
|
display_name: 'Alice',
|
|
|
|
|
|
bio: '',
|
|
|
|
|
|
custom_status: '',
|
|
|
|
|
|
custom_status_emoji: '',
|
2026-09-20 01:32:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-20 11:24:09 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-20 01:32:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 22:03:46 +03:00
|
|
|
|
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'] }),
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
const { router } = renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
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;
|
2026-09-20 01:32:11 +03:00
|
|
|
|
installFetch([
|
2026-09-19 22:03:46 +03:00
|
|
|
|
{ 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 }) },
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
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: 'Алиса' } }),
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
renderApp('/settings/account/profile');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
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',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
renderApp('/settings/account/instance');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|