import { beforeEach, describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { bodyOf, installFetch, json, makeUser, recordedRequests, renderApp, resetStores, type FetchRoute, } from './helpers'; /** * Модерация в настройках сервера (AGENT.md 7.17, 7.18): список банов со * снятием и тайм-аутом, журнал аудита с фильтрами. Фильтры уходят в запрос, * а не фильтруются на клиенте. */ const user = makeUser(); const bans = [ { user_id: 'u-7', username: 'spammer', display_name: 'Spammer', actor_id: 'user-1', actor_name: 'Alice', reason: 'реклама', created_at: '2026-09-20T10:00:00Z', }, { user_id: 'u-8', username: 'noisy', display_name: '', actor_id: 'user-1', actor_name: 'Alice', reason: '', created_at: '2026-09-20T11:00:00Z', }, ]; const audit = [ { id: 'a-2', action: 'member.ban', actor_id: 'user-1', actor_name: 'Alice', target_type: 'user', target_id: 'u-7', reason: 'реклама', created_at: '2026-09-20T10:00:00Z', }, { id: 'a-1', action: 'channel.create', actor_id: 'user-1', actor_name: 'Alice', target_type: 'channel', target_id: 'c-9', created_at: '2026-09-20T09:00:00Z', }, ]; function guildDetail(permissions: string[]) { return { id: 'g-1', name: 'Сервер', description: '', owner_id: 'user-9', is_main: true, member_count: 2, roles: [], my_role_ids: [], my_permissions: permissions, }; } /** Маршруты оболочки, карточки сервера, банов и журнала аудита. */ function routes(permissions: string[] = ['BAN_MEMBERS', 'VIEW_AUDIT_LOG']): FetchRoute[] { // Мок помнит состояние: снятие бана возвращает список без этой записи. let current = bans; return [ { match: '/api/v1/guilds/g-1/bans', response: () => json({ bans: current }) }, { match: '/api/v1/guilds/g-1/bans', method: 'DELETE', response: (request) => { const userId = request.url.split('/').at(-1); current = current.filter((ban) => ban.user_id !== userId); return json({ bans: current }); }, }, { match: '/api/v1/guilds/g-1/audit-log', response: () => json({ entries: audit }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildDetail(permissions)] }), }, { 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: [ { user_id: 'u-7', username: 'spammer', display_name: 'Spammer', status: 'offline', role_ids: [], }, ], }), }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) }, ]; } async function openServerSettings(permissions?: string[]) { const fetchMock = installFetch(routes(permissions)); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); return { dialog, fetchMock }; } /** Последний запрос по URL и методу. */ function lastRequest(fetchMock: ReturnType, url: string, method: string) { return recordedRequests(fetchMock) .filter((request) => request.url.includes(url) && request.method.toUpperCase() === method) .at(-1); } beforeEach(() => { resetStores(); }); describe('модерация: баны', () => { it('показывает список с причиной и автором', async () => { const { dialog } = await openServerSettings(); const list = await within(dialog).findByTestId('guild-bans'); expect(list).toHaveTextContent('Spammer'); expect(within(list).getByTestId('guild-ban-u-7')).toHaveTextContent('реклама'); expect(within(list).getByTestId('guild-ban-u-7')).toHaveTextContent('Alice'); // Пустая причина показывается как «без причины». expect(within(list).getByTestId('guild-ban-u-8')).toHaveTextContent('без причины'); }); it('снимает бан запросом и обновляет список', async () => { const { dialog, fetchMock } = await openServerSettings(); const list = await within(dialog).findByTestId('guild-bans'); await userEvent.click(within(list).getByTestId('guild-ban-remove-u-7')); await waitFor(() => { expect(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'DELETE')).toBeDefined(); }); // Ответ PUT/DELETE — новый список, поэтому строка исчезает без перезапроса. await waitFor(() => { expect(screen.queryByTestId('guild-ban-u-7')).toBeNull(); }); }); it('ставит тайм-аут с выбранным пресетом', async () => { const { dialog, fetchMock } = await openServerSettings(); const list = await within(dialog).findByTestId('guild-bans'); await userEvent.click(screen.getByTestId('ban-timeout-preset-60')); await userEvent.click(within(list).getByTestId('guild-ban-timeout-u-8')); await waitFor(() => { expect(lastRequest(fetchMock, '/guilds/g-1/members/u-8', 'PATCH')).toBeDefined(); }); const request = lastRequest(fetchMock, '/guilds/g-1/members/u-8', 'PATCH'); const body = bodyOf(request) as { timeout_until?: string } | undefined; const until = new Date(body?.timeout_until ?? ''); // Пресет «60» — примерно час вперёд (проверяем с запасом в минуту). const minutes = (until.getTime() - Date.now()) / 60_000; expect(minutes).toBeGreaterThan(58); expect(minutes).toBeLessThan(61); }); it('скрывает секцию без права BAN_MEMBERS', async () => { const { dialog } = await openServerSettings(['VIEW_AUDIT_LOG']); await waitFor(() => { expect(within(dialog).queryByTestId('guild-bans')).toBeNull(); expect(within(dialog).queryByTestId('guild-bans-empty')).toBeNull(); }); }); }); describe('модерация: меню участника', () => { it('банит участника с причиной из меню', async () => { const { dialog, fetchMock } = await openServerSettings(); await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); await userEvent.type(screen.getByTestId('member-ban-reason-u-7'), 'реклама'); await userEvent.click(await screen.findByTestId('member-ban-u-7')); await waitFor(() => { expect(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'PUT')).toBeDefined(); }); const body = bodyOf(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'PUT')) as { reason?: string; }; expect(body.reason).toBe('реклама'); }); it('ставит тайм-аут участнику из меню', async () => { const { dialog, fetchMock } = await openServerSettings([ 'BAN_MEMBERS', 'KICK_MEMBERS', 'TIMEOUT_MEMBERS', 'VIEW_AUDIT_LOG', ]); await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); await userEvent.click(await screen.findByTestId('member-timeout-60')); await waitFor(() => { expect(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'PATCH')).toBeDefined(); }); const body = bodyOf(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'PATCH')) as { timeout_until?: string; }; expect(new Date(body.timeout_until ?? '').getTime()).toBeGreaterThan(Date.now()); }); it('исключает участника из меню', async () => { const { dialog, fetchMock } = await openServerSettings([ 'BAN_MEMBERS', 'KICK_MEMBERS', 'TIMEOUT_MEMBERS', 'VIEW_AUDIT_LOG', ]); await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); await userEvent.click(await screen.findByTestId('member-kick-u-7')); await waitFor(() => { expect(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'DELETE')).toBeDefined(); }); }); it('скрывает меню без прав модерации', async () => { const { dialog } = await openServerSettings(['VIEW_AUDIT_LOG']); await within(dialog).findByTestId('guild-members'); expect(within(dialog).queryByTestId('member-moderate-u-7')).toBeNull(); }); }); describe('модерация: журнал аудита', () => { it('показывает записи и отправляет фильтры в запрос', async () => { const { dialog, fetchMock } = await openServerSettings(); const list = await within(dialog).findByTestId('guild-audit'); expect(list).toHaveTextContent('member.ban'); expect(within(list).getByTestId('guild-audit-a-2')).toHaveTextContent('Alice'); expect(within(list).getByTestId('guild-audit-a-2')).toHaveTextContent('u-7'); await userEvent.type(screen.getByTestId('audit-filter-action'), 'member.ban'); await userEvent.type(screen.getByTestId('audit-filter-target'), 'u-7'); await userEvent.click(screen.getByTestId('audit-apply')); await waitFor(() => { const url = recordedRequests(fetchMock) .filter((item) => item.url.includes('/audit-log')) .at(-1)?.url ?? ''; expect(url, `URL запроса: ${url}`).toContain('action=member.ban'); expect(url).toContain('target_id=u-7'); }); }); it('сброс фильтров возвращает запрос без параметров', async () => { const { dialog, fetchMock } = await openServerSettings(); await within(dialog).findByTestId('guild-audit'); await userEvent.type(screen.getByTestId('audit-filter-action'), 'member.ban'); await userEvent.click(screen.getByTestId('audit-apply')); await userEvent.click(screen.getByTestId('audit-reset')); await waitFor(() => { const url = recordedRequests(fetchMock) .filter((item) => item.url.includes('/audit-log')) .at(-1)?.url ?? ''; expect(url).not.toContain('action='); }); expect(screen.getByTestId('audit-filter-action')).toHaveValue(''); }); it('скрывает журнал без права VIEW_AUDIT_LOG', async () => { const { dialog } = await openServerSettings(['BAN_MEMBERS']); await waitFor(() => { expect(within(dialog).queryByTestId('guild-audit')).toBeNull(); expect(within(dialog).queryByTestId('guild-audit-empty')).toBeNull(); }); }); });