From 96e0cd59180fec2f900059f4f854dc3e7106ac30 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:45:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(templates):=20=D1=81=D0=B2=D0=BE=D0=B8=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B5=20=E2=80=94=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20(=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модалка создания сервера показывает блок «Мои шаблоны» рядом со встроенными и отправляет template_id=personal:; секция «Шаблоны сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру текущего сервера и удаляет ненужные шаблоны. - api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate, ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT; - i18n: ключи в ru.json и en.json (паритет); - тесты: web/tests/guildTemplates.test.tsx. --- web/src/api/guilds.ts | 44 +++- web/src/i18n/locales/en.json | 27 ++- web/src/i18n/locales/ru.json | 27 ++- web/src/pages/app/CreateGuildModal.tsx | 118 +++++++--- web/src/pages/settings/GuildSettingsPage.tsx | 9 + .../pages/settings/GuildTemplatesSection.tsx | 210 +++++++++++++++++ web/tests/guildTemplates.test.tsx | 222 ++++++++++++++++++ 7 files changed, 625 insertions(+), 32 deletions(-) create mode 100644 web/src/pages/settings/GuildTemplatesSection.tsx create mode 100644 web/tests/guildTemplates.test.tsx diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index b7d0bac..0502691 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -17,7 +17,7 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const; export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const; -/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */ +/** Шаблон структуры сервера: встроенный или свой (AGENT.md 7.3). */ export interface GuildTemplate { id: string; name: string; @@ -26,9 +26,16 @@ export interface GuildTemplate { text_channels: number; voice_channels: number; categories: string[]; + /** Личный шаблон: дата создания и сервер-источник (Фаза 7). */ + created_at?: string; + source_guild_id?: string; } +/** Предел личных шаблонов на пользователя — как на сервере. */ +export const MY_GUILD_TEMPLATE_LIMIT = 20; + export const guildTemplatesQueryKey = ['guild-templates'] as const; +export const myGuildTemplatesQueryKey = ['guild-templates', 'mine'] as const; /** `GET /guild-templates` — встроенные шаблоны серверов. */ export async function fetchGuildTemplates(signal?: AbortSignal): Promise { @@ -39,6 +46,41 @@ export async function fetchGuildTemplates(signal?: AbortSignal): Promise { + const payload = await request<{ templates: GuildTemplate[] }>( + '/guild-templates/mine', + signal === undefined ? {} : { signal }, + ); + return payload.templates; +} + +export interface CreateGuildTemplateInput { + guildId: string; + name: string; + description?: string; +} + +/** `POST /guild-templates` — сохранить структуру сервера личным шаблоном. */ +export async function createGuildTemplate(input: CreateGuildTemplateInput): Promise { + const payload = await request<{ template: GuildTemplate }>('/guild-templates', { + method: 'POST', + body: { + guild_id: input.guildId, + name: input.name, + description: input.description ?? '', + }, + }); + return payload.template; +} + +/** `DELETE /guild-templates/{id}` — удалить свой шаблон. */ +export async function deleteGuildTemplate(templateId: string): Promise { + await request<{ ok: boolean }>(`/guild-templates/${encodeURIComponent(templateId)}`, { + method: 'DELETE', + }); +} + /** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */ export async function createGuild(name: string, templateId = ''): Promise { const payload = await request<{ guild: GuildSummary }>('/guilds', { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dfea897..20129af 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -111,6 +111,9 @@ "emojis": { "limit_reached": "The server has reached its emoji limit." }, + "template": { + "limit_reached": "You have reached the personal template limit — delete one of them." + }, "voice.disabled": "Voice is not configured on this instance.", "voice.channel_full": "No free slots in this room.", "voice.token_refresh_failed": "Could not renew the voice session. Please join again.", @@ -256,7 +259,9 @@ "limit": "You can create at most {{count}} servers on this instance.", "limitReached": "You have reached the server limit — remove one of your servers first.", "template": "Server template", - "templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles" + "templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles", + "myTemplates": "My templates", + "myTemplatesEmpty": "No templates yet — save a server structure in its settings." }, "empty": { "title": "You have no servers yet", @@ -1222,6 +1227,26 @@ "role_revoke": "Role revoked" } }, + "templates": { + "title": "Server templates", + "description": "Save the server structure — roles with permissions and colours, categories and rooms — and create new servers from it. Up to {{limit}} templates, visible only to you.", + "counter": "{{count}} of {{limit}}", + "empty": "You have no templates yet.", + "counts": "{{text}} text · {{voice}} voice · {{roles}} roles", + "saveTitle": "Save structure as a template", + "saveHint": "The template includes roles (except the default one), categories and rooms with their settings. Messages are not saved.", + "name": "Template name", + "nameHint": "Up to 64 characters; empty — the server name “{{name}}”.", + "templateDescription": "Template description", + "descriptionPlaceholder": "What is inside the template", + "save": "Save template", + "saved": "Template saved.", + "delete": "Delete", + "deleteLabel": "Delete template {{name}}", + "deleteConfirmTitle": "Delete template?", + "deleteConfirmBody": "Template “{{name}}” will be deleted. Servers created from it stay.", + "deleteConfirm": "Delete" + }, "bans": { "title": "Bans", "description": "Banned users cannot return by invite until the ban is lifted.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 31d9cd7..6939e8d 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -111,6 +111,9 @@ "emojis": { "limit_reached": "На сервере достигнут лимит эмодзи." }, + "template": { + "limit_reached": "Достигнут лимит своих шаблонов — удалите один из них." + }, "voice.disabled": "Голос не настроен на инстансе.", "voice.channel_full": "В комнате нет свободных мест.", "voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.", @@ -256,7 +259,9 @@ "limit": "На инстансе можно создать не более {{count}} серверов.", "limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.", "template": "Шаблон сервера", - "templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли" + "templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли", + "myTemplates": "Мои шаблоны", + "myTemplatesEmpty": "Своих шаблонов пока нет — сохраните структуру сервера в его настройках." }, "empty": { "title": "У вас пока нет серверов", @@ -1222,6 +1227,26 @@ "role_revoke": "Снятие роли" } }, + "templates": { + "title": "Шаблоны сервера", + "description": "Сохраните структуру сервера — роли с правами и цветом, категории и комнаты — и создавайте по ней новые серверы. Своих шаблонов: до {{limit}}, они видны только вам.", + "counter": "{{count}} из {{limit}}", + "empty": "Своих шаблонов пока нет.", + "counts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли", + "saveTitle": "Сохранить структуру как шаблон", + "saveHint": "В шаблон попадут роли (кроме роли по умолчанию), категории и комнаты с их настройками. Сообщения не сохраняются.", + "name": "Название шаблона", + "nameHint": "До 64 символов; пусто — имя сервера «{{name}}».", + "templateDescription": "Описание шаблона", + "descriptionPlaceholder": "Что внутри шаблона", + "save": "Сохранить шаблон", + "saved": "Шаблон сохранён.", + "delete": "Удалить", + "deleteLabel": "Удалить шаблон {{name}}", + "deleteConfirmTitle": "Удалить шаблон?", + "deleteConfirmBody": "Шаблон «{{name}}» будет удалён. Серверы, созданные по нему, останутся.", + "deleteConfirm": "Удалить" + }, "bans": { "title": "Баны", "description": "Забаненные не могут вернуться по приглашению, пока бан не снят.", diff --git a/web/src/pages/app/CreateGuildModal.tsx b/web/src/pages/app/CreateGuildModal.tsx index d67fc21..4a3d514 100644 --- a/web/src/pages/app/CreateGuildModal.tsx +++ b/web/src/pages/app/CreateGuildModal.tsx @@ -3,7 +3,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; -import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds'; +import { + createGuild, + fetchGuildTemplates, + fetchMyGuildTemplates, + guildTemplatesQueryKey, + myGuildTemplatesQueryKey, + type GuildTemplate, +} from '@/api/guilds'; import { instanceQueryKey } from '@/api/instance'; import { myGuildsQueryKey } from '@/api/users'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; @@ -14,6 +21,51 @@ import { useInstance } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; import { useUiStore } from '@/stores/ui'; +/** Значение радио личного шаблона: так его понимает `POST /guilds` (7.3). */ +function personalTemplateValue(templateId: string): string { + return `personal:${templateId}`; +} + +interface TemplateOptionProps { + value: string; + testId: string; + template: GuildTemplate; + selected: boolean; + onSelect: (value: string) => void; +} + +/** Карточка шаблона в списке выбора: имя, счётчики и описание. */ +function TemplateOption({ value, testId, template, selected, onSelect }: TemplateOptionProps) { + const { t } = useTranslation(); + return ( + + ); +} + /** * Единственное модальное окно создания сервера (`POST /guilds {name}`). * Открывается из рейки и с пустого экрана «У вас пока нет серверов», @@ -33,7 +85,7 @@ export function CreateGuildModal() { const [name, setName] = useState(''); const [templateId, setTemplateId] = useState('empty'); - // Шаблоны структуры: список небольшой и статический, кэшируется на сессию. + // Встроенные шаблоны: список небольшой и статический, кэшируется на сессию. const templates = useQuery({ queryKey: guildTemplatesQueryKey, queryFn: ({ signal }) => fetchGuildTemplates(signal), @@ -42,6 +94,15 @@ export function CreateGuildModal() { retry: 0, }); + // Свои шаблоны (Фаза 7): сервер отдаёт только шаблоны текущего пользователя. + const myTemplates = useQuery({ + queryKey: myGuildTemplatesQueryKey, + queryFn: ({ signal }) => fetchMyGuildTemplates(signal), + enabled: open, + staleTime: 60_000, + retry: 0, + }); + const create = useMutation({ mutationFn: (guildName: string) => createGuild(guildName, templateId), onSuccess: (guild) => { @@ -57,6 +118,8 @@ export function CreateGuildModal() { const limitReached = instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user; + const mine = myTemplates.data ?? []; + return ( {t('guilds.create.template')} {(templates.data ?? []).map((template) => ( - + value={template.id} + testId={`guild-template-${template.id}`} + template={template} + selected={templateId === template.id} + onSelect={setTemplateId} + /> ))} +

+ {t('guilds.create.myTemplates')} +

+ {mine.length === 0 ? ( +

{t('guilds.create.myTemplatesEmpty')}

+ ) : ( + mine.map((template) => ( + + )) + )} {templates.isError ? ( void templates.refetch()} /> ) : null} diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index 91ce486..bf86757 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -27,6 +27,7 @@ import { GuildMembersSection } from '@/pages/settings/GuildMembersSection'; import { GuildPicker } from '@/pages/settings/GuildPicker'; import { GuildRolesSection } from '@/pages/settings/GuildRolesSection'; import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection'; +import { GuildTemplatesSection } from '@/pages/settings/GuildTemplatesSection'; /** * Пункт «Сервер» окна настроек: администрирование одного сервера — @@ -230,6 +231,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { {canManageSoundSet ? ( ) : null} + {/* Личные шаблоны (Фаза 7): структуру сохраняет тот, у кого MANAGE_GUILD. */} + {canManage ? ( + + ) : null} (null); + + const list = useQuery({ + queryKey: myGuildTemplatesQueryKey, + queryFn: ({ signal }) => fetchMyGuildTemplates(signal), + retry: 0, + }); + + const templates = list.data ?? []; + const failed = list.isError && list.data === undefined; + const full = templates.length >= MY_GUILD_TEMPLATE_LIMIT; + + const create = useMutation({ + mutationFn: () => + createGuildTemplate({ + guildId, + name: name.trim(), + description: description.trim(), + }), + onSuccess: () => { + setName(''); + setDescription(''); + setSaved(true); + void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey }); + }, + }); + + const remove = useMutation({ + mutationFn: (templateId: string) => deleteGuildTemplate(templateId), + onSuccess: () => { + setRemoving(null); + void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey }); + }, + }); + + const onSubmit = (event: FormEvent): void => { + event.preventDefault(); + setSaved(false); + create.mutate(); + }; + + return ( + +

+ {t('settings.servers.templates.counter', { + count: templates.length, + limit: MY_GUILD_TEMPLATE_LIMIT, + })} +

+ + {list.isPending ? ( + + ) : failed ? ( + void list.refetch()} /> + ) : templates.length === 0 ? ( +

{t('settings.servers.templates.empty')}

+ ) : ( +
    + {templates.map((template) => ( +
  • + + {template.name} + + {t('settings.servers.templates.counts', { + text: template.text_channels, + voice: template.voice_channels, + roles: template.roles.length, + })} + + + +
  • + ))} +
+ )} + + {remove.isError ? : null} + +
+

{t('settings.servers.templates.saveTitle')}

+

{t('settings.servers.templates.saveHint')}

+ {full ? ( +

+ {t('errors.template.limit_reached')} +

+ ) : null} + + setName(event.target.value)} + /> + setDescription(event.target.value)} + /> + + {create.isError ? : null} + {saved && !create.isError ? ( +

+ {t('settings.servers.templates.saved')} +

+ ) : null} + +
+ +
+ + + setRemoving(null)} + footer={ + <> + + + + } + > +

+ {t('settings.servers.templates.deleteConfirmBody', { + name: removing === null ? '' : removing.name, + })} +

+
+
+ ); +} diff --git a/web/tests/guildTemplates.test.tsx b/web/tests/guildTemplates.test.tsx new file mode 100644 index 0000000..fc1e831 --- /dev/null +++ b/web/tests/guildTemplates.test.tsx @@ -0,0 +1,222 @@ +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(); + }); +});