91f415e36d
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли, категории, текстовые и голосовые комнаты без сообщений - POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит фиксирует выбранный шаблон - GET /guild-templates отдаёт каталог с предпросмотром структуры - клиент: выбор шаблона в окне создания сервера - тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
import { vi } from 'vitest';
|
|
|
|
import { GatewayOp } from '@/api/gateway';
|
|
import {
|
|
findRequest,
|
|
installFetch,
|
|
json,
|
|
makeReadySnapshot,
|
|
makeUser,
|
|
messagesRoutes,
|
|
recordedRequests,
|
|
renderApp,
|
|
} from './helpers';
|
|
|
|
/** Подставной WebSocket: сразу отвечает HELLO и присылает READY. */
|
|
class FakeSocket {
|
|
static instances: FakeSocket[] = [];
|
|
|
|
readyState = 0;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
onclose: ((event: CloseEvent) => void) | null = null;
|
|
|
|
constructor(readonly url: string) {
|
|
FakeSocket.instances.push(this);
|
|
}
|
|
|
|
send(): void {
|
|
// IDENTIFY/HEARTBEAT от клиента в этом тесте не проверяются.
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
open(): void {
|
|
this.readyState = 1;
|
|
this.onopen?.(new Event('open'));
|
|
}
|
|
|
|
emit(packet: unknown): void {
|
|
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
|
}
|
|
}
|
|
|
|
function installGatewaySocket(snapshot: unknown) {
|
|
FakeSocket.instances = [];
|
|
vi.stubGlobal('WebSocket', FakeSocket);
|
|
return {
|
|
/** Ждёт, пока GatewayBridge откроет сокет, и проигрывает рукопожатие. */
|
|
async greet() {
|
|
await waitFor(() => {
|
|
expect(FakeSocket.instances.length).toBeGreaterThan(0);
|
|
});
|
|
const socket = FakeSocket.instances[0];
|
|
if (socket === undefined) {
|
|
throw new Error('шлюз не подключился');
|
|
}
|
|
socket.open();
|
|
socket.emit({
|
|
op: GatewayOp.HELLO,
|
|
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
|
|
});
|
|
socket.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: snapshot });
|
|
},
|
|
};
|
|
}
|
|
|
|
const user = makeUser();
|
|
|
|
const snapshot = makeReadySnapshot([
|
|
{
|
|
id: 'g-1',
|
|
name: 'Основной сервер',
|
|
is_main: true,
|
|
channels: [
|
|
{ id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 },
|
|
{ id: 'c-general', name: 'общий', position: 1, parent_id: 'cat-1' },
|
|
{ id: 'c-readonly', name: 'анонсы', position: 2, parent_id: 'cat-1', can_send: false },
|
|
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 3 },
|
|
],
|
|
},
|
|
{ id: 'g-2', name: 'Второй сервер' },
|
|
]);
|
|
|
|
describe('основной экран', () => {
|
|
it('рендерит серверы и комнаты из снапшота READY', async () => {
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|
...messagesRoutes('c-general', []),
|
|
]);
|
|
const gateway = installGatewaySocket(snapshot);
|
|
|
|
renderApp('/app/g-1/c-general');
|
|
// Ждём, пока AuthGuard отдаст профиль и смонтируется оболочка.
|
|
await screen.findByTestId('guild-rail');
|
|
await gateway.greet();
|
|
|
|
// Серверы в рейке.
|
|
const rail = screen.getByTestId('guild-rail');
|
|
await waitFor(() => {
|
|
expect(within(rail).getAllByRole('link')).toHaveLength(2);
|
|
});
|
|
expect(within(rail).getByLabelText('Открыть сервер Второй сервер')).toBeVisible();
|
|
|
|
// Комнаты: категория, текстовая, «только чтение» (can_send=false) и голосовая.
|
|
expect(await screen.findByLabelText('Открыть комнату общий')).toBeVisible();
|
|
expect(screen.getByLabelText('Открыть комнату анонсы')).toBeVisible();
|
|
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
|
|
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
|
|
|
|
// Панель пользователя: имя, статус, кнопки устройств и шестерёнка настроек.
|
|
const panel = screen.getByLabelText('Панель пользователя');
|
|
expect(within(panel).getByText('Alice')).toBeVisible();
|
|
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
|
|
expect(within(panel).getByLabelText('Выключить микрофон')).toBeVisible();
|
|
expect(within(panel).getByLabelText('Включить камеру')).toBeVisible();
|
|
expect(within(panel).getByLabelText('Выключить звук')).toBeVisible();
|
|
// Выход переехал в мини-профиль (проверяется в tests/userMenu.test.tsx).
|
|
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
|
|
|
|
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
|
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
|
|
|
// Панель пользователя стоит под сайдбаром в левой колонке, а подсказка о
|
|
// выходе с сервера — внизу области контента.
|
|
const column = screen.getByTestId('app-sidebar-column');
|
|
const sidebar = screen.getByRole('complementary', { name: 'Комнаты' });
|
|
expect(column).toContainElement(panel);
|
|
expect(sidebar.compareDocumentPosition(panel)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
expect(screen.getByTestId('guild-leave-hint')).toHaveTextContent('Опасная зона');
|
|
|
|
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
|
expect(await screen.findByTestId('composer')).toBeVisible();
|
|
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
|
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
|
|
});
|
|
|
|
it('анимационные классы не мешают рендеру оболочки', async () => {
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
]);
|
|
const gateway = installGatewaySocket(snapshot);
|
|
|
|
renderApp('/app/g-1/c-general');
|
|
await screen.findByTestId('guild-rail');
|
|
await gateway.greet();
|
|
|
|
// Каскад списков и анимация появления страницы — обычные CSS-классы.
|
|
expect(screen.getByTestId('guild-rail')).toHaveClass('gl-stagger');
|
|
expect(document.querySelector('.gl-page')).not.toBeNull();
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText('Открыть комнату общий')).toHaveClass('gl-indicator');
|
|
});
|
|
});
|
|
|
|
it('шестерёнка настроек сервера видна владельцу и открывает администрирование этого сервера', async () => {
|
|
const { default: userEvent } = await import('@testing-library/user-event');
|
|
const visitor = userEvent.setup();
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
|
{
|
|
match: '/api/v1/guilds/g-1',
|
|
response: () =>
|
|
json({
|
|
guild: {
|
|
id: 'g-1',
|
|
name: 'Основной сервер',
|
|
description: 'главный',
|
|
owner_id: 'user-1',
|
|
is_main: true,
|
|
member_count: 1,
|
|
roles: [],
|
|
my_role_ids: [],
|
|
my_permissions: [],
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
const gateway = installGatewaySocket(snapshot);
|
|
|
|
const { router } = renderApp('/app/g-1/c-general');
|
|
await screen.findByTestId('guild-rail');
|
|
await gateway.greet();
|
|
|
|
const gear = await screen.findByLabelText('Настройки сервера');
|
|
await visitor.click(gear);
|
|
|
|
// Настройки — модальное окно: из комнаты никуда не уходим.
|
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
|
expect(router.state.location.pathname).toBe('/app/g-1/c-general');
|
|
// Открылся пункт «Сервер» (администрирование) с этим же сервером.
|
|
const nav = within(dialog).getByTestId('settings-nav');
|
|
expect(within(nav).getByRole('button', { name: 'Сервер' })).toHaveAttribute(
|
|
'aria-current',
|
|
'true',
|
|
);
|
|
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent(
|
|
'Основной сервер',
|
|
);
|
|
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер');
|
|
});
|
|
|
|
it('обычный участник не видит шестерёнку настроек сервера', async () => {
|
|
const memberSnapshot = makeReadySnapshot([
|
|
{
|
|
id: 'g-9',
|
|
name: 'Чужой сервер',
|
|
owner_id: 'user-2',
|
|
my_permissions: [],
|
|
channels: [{ id: 'c-1', name: 'общий', position: 0 }],
|
|
},
|
|
]);
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) },
|
|
]);
|
|
const gateway = installGatewaySocket(memberSnapshot);
|
|
|
|
renderApp('/app/g-9/c-1');
|
|
await screen.findByTestId('guild-rail');
|
|
await gateway.greet();
|
|
|
|
await screen.findByLabelText('Открыть комнату общий');
|
|
expect(screen.queryByLabelText('Настройки сервера')).toBeNull();
|
|
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
|
|
});
|
|
|
|
it('пустой список серверов предлагает создать свой и вступить по приглашению', async () => {
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{
|
|
match: '/api/v1/instance',
|
|
response: () =>
|
|
json({
|
|
instance: {
|
|
name: 'glchat-test',
|
|
registration_enabled: true,
|
|
allow_guild_creation: true,
|
|
max_guilds_per_user: 5,
|
|
main_guild_id: 'g-main',
|
|
},
|
|
}),
|
|
},
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
|
]);
|
|
|
|
renderApp('/app/empty');
|
|
|
|
expect(await screen.findByText('У вас пока нет серверов')).toBeVisible();
|
|
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
|
|
expect(await screen.findByTestId('invite-join')).toBeVisible();
|
|
// Автовступления в «главный сервер» больше нет: только по приглашению.
|
|
expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
|
|
});
|
|
|
|
it('друзья — первая иконка рейки, выше серверов', async () => {
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
]);
|
|
const gateway = installGatewaySocket(snapshot);
|
|
|
|
renderApp('/app/g-1/c-general');
|
|
await screen.findByTestId('guild-rail');
|
|
await gateway.greet();
|
|
|
|
// Друзья идут выше списка серверов, между ними — разделитель.
|
|
const rail = screen.getByTestId('guild-rail');
|
|
expect(screen.getByTestId('friends-rail-link').compareDocumentPosition(rail)).toBe(
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
);
|
|
expect(within(rail).getAllByRole('link')).toHaveLength(2);
|
|
expect(screen.getByRole('separator')).toBeVisible();
|
|
// Личные беседы в рейку не попадают: там только раздел друзей и серверы.
|
|
expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('href', '/app/friends');
|
|
});
|
|
|
|
it('создаёт сервер через модальное окно', async () => {
|
|
const { default: userEvent } = await import('@testing-library/user-event');
|
|
const visitor = userEvent.setup();
|
|
const created = {
|
|
id: 'g-new',
|
|
name: 'Новый',
|
|
owner_id: 'user-1',
|
|
is_main: false,
|
|
member_count: 1,
|
|
my_role_ids: [],
|
|
my_permissions: [],
|
|
};
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{
|
|
match: '/api/v1/instance',
|
|
response: () =>
|
|
json({
|
|
instance: {
|
|
name: 'glchat-test',
|
|
registration_enabled: true,
|
|
allow_guild_creation: true,
|
|
max_guilds_per_user: 5,
|
|
},
|
|
}),
|
|
},
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: created }) },
|
|
{ match: '/api/v1/guilds/g-new/channels', response: () => json({ channels: [] }) },
|
|
{ match: '/api/v1/guilds/g-new', response: () => json({ guild: { ...created, roles: [] } }) },
|
|
]);
|
|
|
|
const { router } = renderApp('/app/empty');
|
|
await visitor.click(await screen.findByLabelText('Создать сервер'));
|
|
|
|
await visitor.type(await screen.findByLabelText('Название сервера'), 'Новый');
|
|
await visitor.click(screen.getByRole('button', { name: 'Создать' }));
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toBe('/app/g-new');
|
|
});
|
|
const createCall = recordedRequests(fetchMock).find(
|
|
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
|
|
);
|
|
// Шаблон по умолчанию — «Пустой»: структура комнат и ролей (AGENT.md 7.3).
|
|
expect(createCall?.body).toEqual({ name: 'Новый', template_id: 'empty' });
|
|
});
|
|
});
|