f1a195ec9d
Банить и исключать нужно того, кто прямо сейчас в сервере, а не только разбирать готовый список банов (AGENT.md 7.17). - `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS, себя модерировать нельзя; - после действия обновляются списки участников и банов (без F5); - тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
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<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();
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|