import { beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { bodyOf, findRequest, installFetch, json, makeReadySnapshot, makeUser, renderApp, resetStores, installGatewaySocket, messagesRoutes, type FetchRoute, } from './helpers'; import { useSessionStore } from '@/stores/session'; import { useUiStore } from '@/stores/ui'; /** * Вебхуки комнаты (AGENT.md 7.11): секция «Комната» в настройках — список, * создание со step-up, пересоздание ссылки, копирование URL и удаление. */ const user = makeUser({ is_instance_admin: true }); const channel = { id: 'c-1', name: 'общий', type: 'text' as const, position: 0 }; function webhook(overrides: Record = {}) { return { id: 'w-1', guild_id: 'g-1', channel_id: 'c-1', name: 'Деплой', token: 'token-1', created_by: 'user-1', created_at: '2026-09-20T10:00:00Z', url: 'https://gl.test/api/v1/webhooks/w-1/token-1', ...overrides, }; } function routes(overrides: { list?: unknown[] } = {}): FetchRoute[] { return [ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [ { id: 'g-1', name: 'Сервер', owner_id: 'user-1', is_main: true, member_count: 1, my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], my_role_ids: [], }, ], }), }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) }, { 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', response: () => json({ guild: { id: 'g-1', name: 'Сервер', owner_id: 'user-1', is_main: true, member_count: 1, my_role_ids: [], my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], channels: [channel], roles: [], }, }), }, { match: '/api/v1/channels/c-1/webhooks', response: () => json({ webhooks: overrides.list ?? [webhook()] }), }, ...messagesRoutes('c-1', []), ]; } beforeEach(() => { resetStores(); }); /** Открывает настройки комнаты с готовым списком вебхуков. */ async function openChannelSettings(list?: unknown[]) { const fetchMock = installFetch(routes(list === undefined ? {} : { list })); const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], channels: [channel], }, ]); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-1'); await gateway.greet(); await screen.findByTestId('message-list'); await userEvent.click(await screen.findByTestId('channel-settings')); const content = await screen.findByTestId('settings-content'); await within(content).findByTestId('channel-webhooks'); return { fetchMock, content }; } describe('вебхуки комнаты', () => { it('показывает список со ссылкой и копирует её', async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, }); const { content } = await openChannelSettings(); const item = within(content).getByTestId('webhook-w-1'); expect(within(item).getByText('Деплой')).toBeVisible(); expect(within(item).getByTestId('webhook-url-w-1')).toHaveValue( 'https://gl.test/api/v1/webhooks/w-1/token-1', ); await userEvent.click(within(item).getByRole('button', { name: 'Скопировать' })); expect(writeText).toHaveBeenCalledWith('https://gl.test/api/v1/webhooks/w-1/token-1'); expect(await within(item).findByText('Скопировано')).toBeInTheDocument(); }); it('создаёт вебхук и подтверждает личность по запросу сервера', async () => { const { fetchMock, content } = await openChannelSettings([]); // Первый раз сервер требует step-up: секция показывает форму подтверждения. fetchMock.mockImplementationOnce(() => Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)), ); fetchMock.mockImplementationOnce(() => Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)), ); await userEvent.click(within(content).getByTestId('webhook-create-open')); const dialog = await screen.findByRole('dialog', { name: 'Создать вебхук' }); await userEvent.type(within(dialog).getByLabelText('Имя вебхука'), 'Новый'); await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' })); const stepUpField = await within(dialog).findByLabelText('Ваш пароль'); await userEvent.type(stepUpField, 'correct-horse-battery'); await userEvent.click(within(dialog).getByRole('button', { name: 'Подтвердить' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/auth/step-up', method: 'POST' })).toBeDefined(); }); await waitFor(() => { const created = findRequest(fetchMock, { url: '/channels/c-1/webhooks', method: 'POST' }); expect(bodyOf(created)).toMatchObject({ name: 'Новый' }); }); }); it('пересоздаёт ссылку и удаляет вебхук', async () => { vi.spyOn(window, 'confirm').mockReturnValue(true); const { fetchMock, content } = await openChannelSettings(); const item = within(content).getByTestId('webhook-w-1'); await userEvent.click(within(item).getByTestId('webhook-rotate-w-1')); await waitFor(() => { expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'PATCH' })?.body).toMatchObject( { regenerate_token: true }, ); }); await userEvent.click(within(item).getByTestId('webhook-delete-w-1')); await waitFor(() => { expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'DELETE' })).toBeDefined(); }); }); it('не показывает секцию без права MANAGE_WEBHOOKS', async () => { const fetchMock = installFetch(routes({ list: [{ ...webhook(), id: 'w-2' }] })); const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, // Права есть, но владелец — другой пользователь и админа нет. owner_id: 'user-9', my_permissions: [], channels: [channel], }, ]); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-1'); await gateway.greet(); await screen.findByTestId('message-list'); // Кнопки настроек комнаты нет вовсе: секция недоступна из интерфейса. expect(screen.queryByTestId('channel-settings')).toBeNull(); expect(useSessionStore.getState().guilds[0]?.my_permissions).toEqual([]); await waitFor(() => { expect(fetchMock).toBeDefined(); }); }); it('открывает настройки комнаты из шапки чата', async () => { await openChannelSettings(); expect(useUiStore.getState().settingsOpen).toBe(true); expect(useUiStore.getState().settingsSection).toBe('channel'); expect(useSessionStore.getState().settingsChannelId).toBe('c-1'); }); });