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/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 saveButton(index = 0): HTMLElement { const buttons = screen.getAllByRole('button', { name: 'Сохранить' }); const button = buttons[index]; if (button === undefined) { throw new Error('кнопка сохранения не найдена'); } return button; } describe('настройки серверов', () => { it('показывает список серверов с иконкой и именем и выбирает первый', async () => { installFetch(serversRoutes()); const { router } = renderApp('/settings/servers'); await waitFor(() => { expect(router.state.location.pathname).toBe('/settings/servers/g-1'); }); // Первый сервер подсвечен. Список запрашиваем заново: после выбора // страница перерисовывается, и прежний узел может быть уже отсоединён. await waitFor(() => { const picker = screen.getByTestId('guild-picker'); expect( within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }), ).toHaveAttribute('aria-current', 'page'); }); const picker = screen.getByTestId('guild-picker'); const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }); // Иконка сервера отдаётся файловым сервисом по icon_file_id. expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1'); expect( within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }), ).toBeVisible(); expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа'); }); it('переключение сервера меняет отображаемые настройки и права', async () => { installFetch(serversRoutes()); const { router } = renderApp('/settings/servers/g-1'); expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа'); // Владелец может редактировать. expect(screen.getByLabelText('Название сервера')).toBeEnabled(); await userEvent.click( await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }), ); await waitFor(() => { expect(router.state.location.pathname).toBe('/settings/servers/g-2'); }); 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' }, 'Новое') }), }, ]), ); renderApp('/settings/servers/g-1'); 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(saveButton()); 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 }), }, ]), ); renderApp('/settings/servers/g-1'); const nickname = await screen.findByLabelText('Никнейм'); await waitFor(() => { expect(nickname).toHaveValue('Аля'); }); await userEvent.clear(nickname); await userEvent.type(nickname, 'Алиса'); // Вторая кнопка «Сохранить» — секция «Мой профиль на сервере». await userEvent.click(saveButton(1)); 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 () => { installFetch(serversRoutes()); renderApp('/settings/servers/g-1'); 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()); renderApp('/settings/servers/g-1'); expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible(); expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull(); expect(screen.getByRole('button', { name: 'Удалить сервер' })).toBeVisible(); }); it('участник без прав не видит удаление сервера', async () => { installFetch(serversRoutes()); renderApp('/settings/servers/g-2'); expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull(); }); it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => { installFetch(serversRoutes()); renderApp('/settings/servers/g-2'); 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')).toBeNull(); }); }); it('покинуть сервер можно после подтверждения в модальном окне', async () => { const fetchMock = installFetch( serversRoutes([ { match: '/api/v1/guilds/g-2/leave', method: 'POST', response: () => json({ ok: true }) }, ]), ); const { router } = renderApp('/settings/servers/g-2'); 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 } = renderApp('/settings/servers/g-1'); 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 }) }, ]); renderApp('/settings/servers'); expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible(); expect(screen.getByText('У вас пока нет серверов.')).toBeVisible(); }); });