import { beforeEach, describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { bodyOf, findRequest, installFetch, json, makeUser, recordedRequests, renderApp, resetStores, type FetchRoute, } from './helpers'; /** * Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): модалка создания сервера * показывает свои шаблоны рядом со встроенными и отправляет `personal:`, * а секция «Шаблоны сервера» сохраняет структуру и удаляет шаблоны. */ const user = makeUser(); const builtinTemplate = { id: 'empty', name: 'Пустой', description: 'Одна общая текстовая комната.', roles: ['Администратор', 'Пользователь'], text_channels: 1, voice_channels: 0, categories: [], }; const myTemplate = { id: 't-1', name: 'Моя структура', description: 'Снято с сервера', roles: ['Администратор', 'Пользователь', 'Модератор'], text_channels: 2, voice_channels: 1, categories: ['Информация'], created_at: '2026-09-26T10:00:00.000Z', source_guild_id: 'g-1', }; const guildSummary = { id: 'g-1', name: 'Сервер', description: '', owner_id: 'user-9', is_main: true, member_count: 1, roles: [], my_role_ids: [], my_permissions: ['MANAGE_GUILD'], }; /** Маршруты оболочки приложения, каталога шаблонов и своих шаблонов. */ function appRoutes(extra: FetchRoute[] = []): FetchRoute[] { return [ { match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) }, { match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) }, ...extra, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild: guildSummary }) }, ]; } /** Открывает модалку создания сервера и дожидается списка шаблонов. */ async function openCreateGuildModal() { renderApp('/app'); await userEvent.click(await screen.findByLabelText('Создать сервер')); return screen.findByRole('dialog', { name: 'Создать сервер' }); } /** Открывает настройки сервера и дожидается секции «Шаблоны сервера». */ async function openTemplatesSection() { renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); const counter = await within(dialog).findByTestId('guild-templates-counter'); return { dialog, counter }; } beforeEach(() => { resetStores(); }); describe('создание сервера по своему шаблону', () => { it('показывает свои шаблоны и отправляет template_id personal:', async () => { const fetchMock = installFetch( appRoutes([ { match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: { id: 'g-new', name: 'Новый', owner_id: 'user-1', is_main: false, member_count: 1, my_role_ids: [], my_permissions: ['ADMINISTRATOR'], }, }), }, ]), ); const dialog = await openCreateGuildModal(); const option = await within(dialog).findByTestId('guild-template-personal-t-1'); expect(within(option).getByText('Моя структура')).toBeVisible(); // Счётчики как у встроенных шаблонов: 2 текстовые, 1 голосовая, 3 роли. expect(within(option).getByText(/2 текст/)).toBeVisible(); await userEvent.click(within(option).getByRole('radio')); await userEvent.type(within(dialog).getByLabelText('Название сервера'), 'Новый'); await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' })); await waitFor(() => { expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toEqual({ name: 'Новый', template_id: 'personal:t-1', }); }); }); it('без своих шаблонов показывает подсказку и встроенные шаблоны', async () => { installFetch([ { match: '/api/v1/guild-templates/mine', response: () => json({ templates: [] }) }, { match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]); const dialog = await openCreateGuildModal(); expect(await within(dialog).findByTestId('guild-template-empty')).toBeVisible(); expect(within(dialog).getByTestId('my-guild-templates')).toBeVisible(); expect(within(dialog).getByText(/Своих шаблонов пока нет/)).toBeVisible(); }); }); describe('настройки сервера: секция «Шаблоны сервера»', () => { it('сохраняет структуру сервера и удаляет шаблон', async () => { const fetchMock = installFetch( appRoutes([ { match: '/api/v1/guild-templates', method: 'POST', response: () => json({ template: { ...myTemplate, id: 't-2', name: 'Копия' } }), }, { match: '/api/v1/guild-templates/t-1', method: 'DELETE', response: () => json({ ok: true }), }, ]), ); const { dialog } = await openTemplatesSection(); expect(await within(dialog).findByTestId('my-guild-template-t-1')).toBeVisible(); // Поля формы «Сохранить структуру как шаблон»: «Описание» есть и у секции // «Общее», поэтому подпись шаблона отличается, а поиск идёт внутри формы. const form = within(dialog).getByTestId('guild-templates-form'); await userEvent.type(within(form).getByLabelText('Название шаблона'), 'Копия'); await userEvent.type(within(form).getByLabelText('Описание шаблона'), 'Снимок структуры'); await userEvent.click(within(form).getByRole('button', { name: 'Сохранить шаблон' })); expect(await within(dialog).findByText('Шаблон сохранён.')).toBeVisible(); const post = recordedRequests(fetchMock).find( (request) => request.url.includes('/guild-templates') && request.method === 'POST', ); expect(post?.url).toContain('/api/v1/guild-templates'); expect(post?.body).toEqual({ guild_id: 'g-1', name: 'Копия', description: 'Снимок структуры', }); await userEvent.click( within(dialog).getByRole('button', { name: 'Удалить шаблон Моя структура' }), ); const confirm = await screen.findByRole('dialog', { name: 'Удалить шаблон?' }); await userEvent.click(within(confirm).getByRole('button', { name: 'Удалить' })); await waitFor(() => { expect( findRequest(fetchMock, { url: '/guild-templates/t-1', method: 'DELETE' }), ).toBeDefined(); }); }); it('не показывает секцию участнику без права MANAGE_GUILD', async () => { const member = { ...guildSummary, owner_id: 'user-9', my_permissions: ['VIEW_GUILD'] }; installFetch([ { match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [member] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild: member }) }, ]); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); // Секция «Общее» отрисовалась — значит страница настроек загрузилась. expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible(); expect(within(dialog).queryByTestId('guild-templates-counter')).toBeNull(); }); });