20eed227ca
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import {
|
|
findRequest,
|
|
installFetch,
|
|
json,
|
|
makeUser,
|
|
recordedRequests,
|
|
renderApp,
|
|
type FetchRoute,
|
|
} from './helpers';
|
|
import type { GuildMember, GuildSummary, Role } from '@/api/types';
|
|
|
|
const user = makeUser();
|
|
|
|
const alpha: GuildSummary = {
|
|
id: 'g-1',
|
|
name: 'Альфа',
|
|
icon_file_id: 'icon-1',
|
|
owner_id: 'user-1',
|
|
is_main: false,
|
|
member_count: 2,
|
|
my_role_ids: [],
|
|
my_permissions: ['MANAGE_GUILD'],
|
|
};
|
|
|
|
const beta: GuildSummary = {
|
|
id: 'g-2',
|
|
name: 'Бета',
|
|
owner_id: 'user-9',
|
|
is_main: false,
|
|
member_count: 1,
|
|
my_role_ids: [],
|
|
my_permissions: [],
|
|
};
|
|
|
|
const members: GuildMember[] = [
|
|
{
|
|
user_id: 'user-1',
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
nickname: 'Аля',
|
|
status: 'online',
|
|
is_instance_admin: false,
|
|
joined_at: '2026-09-01T10:00:00Z',
|
|
role_ids: ['r-1'],
|
|
},
|
|
{
|
|
user_id: 'user-2',
|
|
username: 'bob',
|
|
display_name: 'Bob',
|
|
status: 'idle',
|
|
is_instance_admin: false,
|
|
joined_at: '2026-09-02T10:00:00Z',
|
|
role_ids: [],
|
|
},
|
|
];
|
|
|
|
const roles: Role[] = [
|
|
{
|
|
id: 'r-1',
|
|
name: 'Администратор',
|
|
color: 0xff0000,
|
|
position: 5,
|
|
permissions: 'ADMINISTRATOR',
|
|
is_default: false,
|
|
hoist: true,
|
|
mentionable: true,
|
|
},
|
|
{
|
|
id: 'r-0',
|
|
name: 'Все',
|
|
color: 0,
|
|
position: 0,
|
|
permissions: '0',
|
|
is_default: true,
|
|
hoist: false,
|
|
mentionable: false,
|
|
},
|
|
];
|
|
|
|
/** Ответ `GET /guilds/{id}`: сводка + описание и роли. */
|
|
function guildDetail(summary: GuildSummary, description: string) {
|
|
return { ...summary, description, roles };
|
|
}
|
|
|
|
/** Таблица моков страницы настроек серверов. */
|
|
function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
|
return [
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
|
|
{ match: '/api/v1/guilds/g-2/channels', response: () => json({ channels: [] }) },
|
|
{ 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/members', response: () => json({ members }) },
|
|
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [members[1]] }) },
|
|
{
|
|
match: '/api/v1/guilds/g-1',
|
|
response: () => json({ guild: guildDetail(alpha, 'Первый сервер') }),
|
|
},
|
|
{
|
|
match: '/api/v1/guilds/g-2',
|
|
response: () => json({ guild: guildDetail(beta, 'Второй сервер') }),
|
|
},
|
|
...extra,
|
|
];
|
|
}
|
|
|
|
/** Кнопка «Сохранить» внутри формы конкретного поля. */
|
|
function saveNear(label: string): HTMLElement {
|
|
const field = screen.getByLabelText(label);
|
|
const form = field.closest('form');
|
|
if (form === null) {
|
|
throw new Error(`форма для «${label}» не найдена`);
|
|
}
|
|
return within(form).getByRole('button', { name: 'Сохранить' });
|
|
}
|
|
|
|
/**
|
|
* Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов».
|
|
* Так же их открывает и обычный участник без прав администратора.
|
|
*/
|
|
async function openServerSettings(path = '/app/empty', guildName?: string) {
|
|
const view = renderApp(path);
|
|
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
|
await userEvent.click(trigger);
|
|
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
|
|
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
|
|
await screen.findByRole('dialog', { name: 'Настройки' });
|
|
|
|
if (guildName !== undefined) {
|
|
const picker = await openGuildPicker();
|
|
await userEvent.click(within(picker).getByRole('menuitemradio', { name: guildName }));
|
|
}
|
|
return view;
|
|
}
|
|
|
|
/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */
|
|
async function openGuildPicker(): Promise<HTMLElement> {
|
|
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
|
|
return screen.findByRole('menu', { name: 'Мои серверы' });
|
|
}
|
|
|
|
describe('настройки серверов', () => {
|
|
it('показывает выпадающий список серверов с иконкой и именем и выбирает первый', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings();
|
|
|
|
// Первый сервер выбран по умолчанию.
|
|
expect(await screen.findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
|
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
|
|
|
const picker = await openGuildPicker();
|
|
const first = within(picker).getByRole('menuitemradio', { name: 'Альфа' });
|
|
expect(first).toHaveAttribute('aria-checked', 'true');
|
|
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
|
|
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
|
|
expect(within(picker).getByRole('menuitemradio', { name: 'Бета' })).toBeVisible();
|
|
});
|
|
|
|
it('переключение сервера меняет отображаемые настройки и права', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings();
|
|
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
|
// Владелец может редактировать.
|
|
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
|
|
|
|
const picker = await openGuildPicker();
|
|
await userEvent.click(within(picker).getByRole('menuitemradio', { name: 'Бета' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
|
|
});
|
|
// Участник без прав видит поля только для чтения и подсказку.
|
|
expect(screen.getByLabelText('Название сервера')).toBeDisabled();
|
|
expect(screen.getByText(/Редактирование доступно владельцу/)).toBeVisible();
|
|
});
|
|
|
|
it('сохраняет имя и описание сервера через PATCH /guilds/{id}', async () => {
|
|
const fetchMock = installFetch(
|
|
serversRoutes([
|
|
{
|
|
match: '/api/v1/guilds/g-1',
|
|
method: 'PATCH',
|
|
response: () => json({ guild: guildDetail({ ...alpha, name: 'Альфа 2' }, 'Новое') }),
|
|
},
|
|
]),
|
|
);
|
|
|
|
await openServerSettings();
|
|
|
|
const name = await screen.findByLabelText('Название сервера');
|
|
await userEvent.clear(name);
|
|
await userEvent.type(name, 'Альфа 2');
|
|
const description = screen.getByLabelText('Описание');
|
|
await userEvent.clear(description);
|
|
await userEvent.type(description, 'Новое');
|
|
await userEvent.click(saveNear('Название сервера'));
|
|
|
|
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
|
|
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
|
|
name: 'Альфа 2',
|
|
description: 'Новое',
|
|
});
|
|
});
|
|
|
|
it('сохраняет свой никнейм через PATCH /guilds/{id}/members/{me}', async () => {
|
|
const fetchMock = installFetch(
|
|
serversRoutes([
|
|
{
|
|
match: '/api/v1/guilds/g-1/members/user-1',
|
|
method: 'PATCH',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
]),
|
|
);
|
|
|
|
await openServerSettings();
|
|
|
|
const nickname = await screen.findByLabelText('Никнейм');
|
|
await waitFor(() => {
|
|
expect(nickname).toHaveValue('Аля');
|
|
});
|
|
|
|
await userEvent.clear(nickname);
|
|
await userEvent.type(nickname, 'Алиса');
|
|
await userEvent.click(saveNear('Никнейм'));
|
|
|
|
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
|
|
const patch = recordedRequests(fetchMock).find(
|
|
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
|
|
);
|
|
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
|
});
|
|
|
|
it('применяет введённый ник ко всем серверам по очереди', async () => {
|
|
const fetchMock = installFetch(
|
|
serversRoutes([
|
|
{
|
|
match: '/api/v1/guilds/g-1/members/user-1',
|
|
method: 'PATCH',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
{
|
|
match: '/api/v1/guilds/g-2/members/user-1',
|
|
method: 'PATCH',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
]),
|
|
);
|
|
|
|
await openServerSettings();
|
|
|
|
const nickname = await screen.findByLabelText('Никнейм');
|
|
await userEvent.clear(nickname);
|
|
await userEvent.type(nickname, 'Алиса');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Применить ко всем серверам' }));
|
|
|
|
expect(await screen.findByText('Никнейм применён на всех серверах (2).')).toBeVisible();
|
|
|
|
const patches = recordedRequests(fetchMock).filter(
|
|
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
|
|
);
|
|
expect(patches.map((request) => request.url.split('/api/v1')[1])).toEqual([
|
|
'/guilds/g-1/members/user-1',
|
|
'/guilds/g-2/members/user-1',
|
|
]);
|
|
expect(
|
|
patches.every((request) => JSON.stringify(request.body) === '{"nickname":"Алиса"}'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('показывает участников и роли сервера', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings();
|
|
|
|
const list = await screen.findByTestId('guild-members');
|
|
expect(within(list).getByText('Аля')).toBeVisible();
|
|
expect(within(list).getByText('Bob')).toBeVisible();
|
|
expect(within(list).getByText('Ролей: 1')).toBeVisible();
|
|
|
|
const roleList = await screen.findByTestId('guild-roles');
|
|
expect(within(roleList).getByText('Администратор')).toBeVisible();
|
|
expect(within(roleList).getByText('Позиция 5')).toBeVisible();
|
|
});
|
|
|
|
it('владелец не может покинуть сервер, но может его удалить', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings();
|
|
|
|
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
|
|
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
|
|
});
|
|
|
|
it('участник без прав не видит удаление сервера', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings('/app/empty', 'Бета');
|
|
|
|
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
|
|
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
|
});
|
|
|
|
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings('/app/empty', 'Бета');
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
|
|
});
|
|
// Окно настроек при этом остаётся открытым.
|
|
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
|
|
});
|
|
|
|
it('Escape закрывает только верхнее окно подтверждения', async () => {
|
|
installFetch(serversRoutes());
|
|
|
|
await openServerSettings('/app/empty', 'Бета');
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
|
await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
|
|
|
await userEvent.keyboard('{Escape}');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
|
|
});
|
|
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
|
|
});
|
|
|
|
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
|
|
const fetchMock = installFetch(
|
|
serversRoutes([
|
|
{ match: '/api/v1/guilds/g-2/leave', method: 'POST', response: () => json({ ok: true }) },
|
|
]),
|
|
);
|
|
|
|
const { router } = await openServerSettings('/app/empty', 'Бета');
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
|
expect(dialog).toHaveTextContent('Вы выйдете из сервера «Бета»');
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Покинуть' }));
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toContain('/app');
|
|
});
|
|
expect(findRequest(fetchMock, { url: '/guilds/g-2/leave', method: 'POST' })).toBeDefined();
|
|
});
|
|
|
|
it('удаление сервера владельцем уходит в DELETE /guilds/{id}', async () => {
|
|
const fetchMock = installFetch(
|
|
serversRoutes([
|
|
{ match: '/api/v1/guilds/g-1', method: 'DELETE', response: () => json({ ok: true }) },
|
|
]),
|
|
);
|
|
|
|
const { router } = await openServerSettings();
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Удалить' }));
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toContain('/app');
|
|
});
|
|
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'DELETE' })).toBeDefined();
|
|
});
|
|
|
|
it('без серверов показывает понятную заглушку', async () => {
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
]);
|
|
|
|
await openServerSettings();
|
|
|
|
const content = screen.getByTestId('settings-content');
|
|
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
|
expect(within(content).getByText('У вас пока нет серверов.')).toBeVisible();
|
|
});
|
|
});
|