feat(templates): свои шаблоны в клиенте — создание сервера и настройки (Фаза 7)
Модалка создания сервера показывает блок «Мои шаблоны» рядом со встроенными и отправляет template_id=personal:<id>; секция «Шаблоны сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру текущего сервера и удаляет ненужные шаблоны. - api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate, ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT; - i18n: ключи в ru.json и en.json (паритет); - тесты: web/tests/guildTemplates.test.tsx.
This commit is contained in:
@@ -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:<id>`,
|
||||
* а секция «Шаблоны сервера» сохраняет структуру и удаляет шаблоны.
|
||||
*/
|
||||
|
||||
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:<id>', 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user