223 lines
9.4 KiB
TypeScript
223 lines
9.4 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|