Files
glchat/web/tests/guildTemplates.test.tsx
T

223 lines
9.4 KiB
TypeScript
Raw Normal View History

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();
});
});