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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user