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 { 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(); }); });