feat(web): разделы «Баны» и «Журнал аудита» в настройках сервера

Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса.

- `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал
  аудита с фильтрами;
- «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута
  (1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому
  строка исчезает без перезагрузки;
- «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос,
  кнопки «Показать» и «Сбросить»;
- секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG
  (`canBanMembers`, `canViewAuditLog`);
- тесты: список с причиной и автором, снятие бана с обновлением списка,
  тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
This commit is contained in:
2026-09-20 22:43:56 +03:00
parent 4fb3509128
commit 8613989b21
7 changed files with 764 additions and 3 deletions
+235
View File
@@ -0,0 +1,235 @@
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: [] }) },
{ 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<typeof installFetch>, 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();
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();
});
});
});