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
+114
View File
@@ -0,0 +1,114 @@
import { request } from './client';
/** Запись о бане сервера (AGENT.md 7.17). */
export interface GuildBan {
user_id: string;
username: string;
display_name?: string;
actor_id?: string;
actor_name?: string;
reason?: string;
created_at: string;
}
/** Запись журнала аудита сервера (AGENT.md 7.18). */
export interface GuildAuditEntry {
id: string;
action: string;
actor_id?: string;
actor_name?: string;
target_type?: string;
target_id?: string;
reason?: string;
created_at: string;
}
export interface AuditFilters {
action?: string;
actorId?: string;
targetId?: string;
beforeId?: string;
limit?: number;
}
export const guildBansQueryKey = (guildId: string) => ['guilds', guildId, 'bans'] as const;
export const guildAuditQueryKey = (guildId: string, filters: AuditFilters) =>
['guilds', guildId, 'audit', filters] as const;
function auditQuery(filters: AuditFilters): string {
const params = new URLSearchParams();
params.set('limit', String(filters.limit ?? 50));
if (filters.action !== undefined && filters.action !== '') {
params.set('action', filters.action);
}
if (filters.actorId !== undefined && filters.actorId !== '') {
params.set('actor_id', filters.actorId);
}
if (filters.targetId !== undefined && filters.targetId !== '') {
params.set('target_id', filters.targetId);
}
if (filters.beforeId !== undefined && filters.beforeId !== '') {
params.set('before_id', filters.beforeId);
}
return params.toString();
}
export async function fetchGuildBans(guildId: string, signal?: AbortSignal): Promise<GuildBan[]> {
const payload = await request<{ bans: GuildBan[] }>(
`/guilds/${encodeURIComponent(guildId)}/bans`,
signal === undefined ? {} : { signal },
);
return payload.bans;
}
/** Бан участника: сервер исключает его и запрещает повторный вход. */
export async function banGuildMember(
guildId: string,
userId: string,
reason: string,
): Promise<GuildBan[]> {
const payload = await request<{ bans: GuildBan[] }>(
`/guilds/${encodeURIComponent(guildId)}/bans/${encodeURIComponent(userId)}`,
{ method: 'PUT', body: { reason } },
);
return payload.bans;
}
export async function unbanGuildMember(guildId: string, userId: string): Promise<GuildBan[]> {
const payload = await request<{ bans: GuildBan[] }>(
`/guilds/${encodeURIComponent(guildId)}/bans/${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
);
return payload.bans;
}
/** Тайм-аут участника: пустая строка снимает ограничение. */
export async function timeoutGuildMember(
guildId: string,
userId: string,
timeoutUntil: string,
): Promise<void> {
await request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`,
{ method: 'PATCH', body: { timeout_until: timeoutUntil } },
);
}
export async function kickGuildMember(guildId: string, userId: string): Promise<void> {
await request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
);
}
export async function fetchGuildAudit(
guildId: string,
filters: AuditFilters,
signal?: AbortSignal,
): Promise<GuildAuditEntry[]> {
const payload = await request<{ entries: GuildAuditEntry[] }>(
`/guilds/${encodeURIComponent(guildId)}/audit-log?${auditQuery(filters)}`,
signal === undefined ? {} : { signal },
);
return payload.entries;
}
+26 -1
View File
@@ -756,7 +756,7 @@
"ping": "Ping",
"pingValue": "{{ms}} ms",
"pingUnavailable": "—",
"pingHint": "Instance response time for a dashboard request",
"pingHint": "Instance response time for a dashboard request (HTTP)",
"summary": {
"ok": "All good",
"warn": "Warnings",
@@ -918,6 +918,31 @@
"role_grant": "Role granted",
"role_revoke": "Role revoked"
}
},
"bans": {
"title": "Bans",
"description": "Banned users cannot return by invite until the ban is lifted.",
"empty": "No bans.",
"remove": "Unban",
"noReason": "no reason",
"bannedBy": "banned by {{name}}, {{date}}",
"unknownActor": "unknown",
"timeoutLabel": "Timeout:",
"timeoutMinutes_one": "{{count}} minute",
"timeoutMinutes_few": "{{count}} minutes",
"timeoutMinutes_many": "{{count}} minutes",
"timeoutMinutes_other": "{{count}} minutes",
"timeoutAction": "Timeout {{minutes}} min"
},
"audit": {
"title": "Audit log",
"description": "Guild moderator actions. Filters are applied server-side.",
"filterAction": "Action",
"filterActor": "Actor ID",
"filterTarget": "Target ID",
"apply": "Apply",
"reset": "Reset",
"empty": "No entries."
}
},
"navLabel": "Settings sections",
+26 -1
View File
@@ -756,7 +756,7 @@
"ping": "Пинг",
"pingValue": "{{ms}} мс",
"pingUnavailable": "—",
"pingHint": "Время ответа инстанса на запрос дашборда",
"pingHint": "Время ответа инстанса на запрос дашборда (HTTP)",
"summary": {
"ok": "Всё в порядке",
"warn": "Есть предупреждения",
@@ -918,6 +918,31 @@
"role_grant": "Выдача роли",
"role_revoke": "Снятие роли"
}
},
"bans": {
"title": "Баны",
"description": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
"empty": "Забаненных нет.",
"remove": "Снять бан",
"noReason": "без причины",
"bannedBy": "забанил {{name}}, {{date}}",
"unknownActor": "неизвестный",
"timeoutLabel": "Тайм-аут:",
"timeoutMinutes_one": "{{count}} минута",
"timeoutMinutes_few": "{{count}} минуты",
"timeoutMinutes_many": "{{count}} минут",
"timeoutMinutes_other": "{{count}} минуты",
"timeoutAction": "Тайм-аут {{minutes}} мин"
},
"audit": {
"title": "Журнал аудита",
"description": "Действия модераторов сервера. Фильтры уходят в запрос.",
"filterAction": "Действие",
"filterActor": "ID автора",
"filterTarget": "ID цели",
"apply": "Показать",
"reset": "Сбросить",
"empty": "Записей нет."
}
},
"navLabel": "Пункты настроек",
+40
View File
@@ -42,6 +42,8 @@ export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS';
export const PERMISSION_BAN_MEMBERS = 'BAN_MEMBERS';
export const PERMISSION_VIEW_AUDIT_LOG = 'VIEW_AUDIT_LOG';
export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
@@ -188,6 +190,44 @@ export function canManageEmojis(
);
}
/**
* Может ли пользователь банить участников (AGENT.md 7.17): владелец,
* ADMINISTRATOR, BAN_MEMBERS или администратор инстанса.
*/
export function canBanMembers(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_BAN_MEMBERS)
);
}
/**
* Может ли пользователь смотреть журнал аудита (AGENT.md 7.18): владелец,
* ADMINISTRATOR, VIEW_AUDIT_LOG или администратор инстанса.
*/
export function canViewAuditLog(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_VIEW_AUDIT_LOG)
);
}
/**
* Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец,
* ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса.
@@ -0,0 +1,299 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
fetchGuildAudit,
fetchGuildBans,
guildAuditQueryKey,
guildBansQueryKey,
timeoutGuildMember,
unbanGuildMember,
type AuditFilters,
} from '@/api/moderation';
import { guildMembersQueryKey } from '@/api/guilds';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Badge, Button } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Пресеты тайм-аута: значения в минутах (0 — снять ограничение). */
const TIMEOUT_PRESETS = [1, 5, 10, 60, 1440] as const;
interface GuildBansSectionProps {
guildId: string;
canBan: boolean;
}
/**
* «Баны»: список забаненных с причиной и автором, снятие бана и установка
* тайм-аута участнику (AGENT.md 7.17). Данные обновляются без перезагрузки.
*/
export function GuildBansSection({ guildId, canBan }: GuildBansSectionProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const describeError = useApiErrorMessage();
const queryClient = useQueryClient();
const [timeoutMinutes, setTimeoutMinutes] = useState<number>(10);
const bans = useQuery({
queryKey: guildBansQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildBans(guildId, signal),
enabled: canBan,
retry: 0,
});
const unban = useMutation({
mutationFn: (userId: string) => unbanGuildMember(guildId, userId),
onSuccess: (list) => {
queryClient.setQueryData(guildBansQueryKey(guildId), list);
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
},
});
const timeout = useMutation({
mutationFn: ({ userId, minutes }: { userId: string; minutes: number }) =>
timeoutGuildMember(
guildId,
userId,
minutes === 0
? '2000-01-01T00:00:00Z'
: new Date(Date.now() + minutes * 60_000).toISOString(),
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
},
});
if (!canBan) {
return null;
}
return (
<SettingsSection
title={t('settings.servers.bans.title')}
description={t('settings.servers.bans.description')}
>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-fg-muted">{t('settings.servers.bans.timeoutLabel')}</span>
{TIMEOUT_PRESETS.map((minutes) => (
<Button
key={minutes}
variant={timeoutMinutes === minutes ? 'primary' : 'ghost'}
onClick={() => setTimeoutMinutes(minutes)}
data-testid={`ban-timeout-preset-${String(minutes)}`}
>
{t('settings.servers.bans.timeoutMinutes', { count: minutes })}
</Button>
))}
</div>
{bans.isPending ? <LoadingNotice /> : null}
{bans.isError ? (
<ErrorNotice className="mt-4" error={bans.error} onRetry={() => void bans.refetch()} />
) : null}
{unban.isError ? (
<p className="mt-3 text-sm text-danger">{describeError(unban.error)}</p>
) : null}
{timeout.isError ? (
<p className="mt-3 text-sm text-danger">{describeError(timeout.error)}</p>
) : null}
{bans.data !== undefined && bans.data.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-bans-empty">
{t('settings.servers.bans.empty')}
</p>
) : null}
{bans.data === undefined || bans.data.length === 0 ? null : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-bans">
{bans.data.map((ban) => (
<li
key={ban.user_id}
className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
data-testid={`guild-ban-${ban.user_id}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{ban.display_name === undefined || ban.display_name === ''
? ban.username
: ban.display_name}{' '}
<span className="text-fg-muted">@{ban.username}</span>
</span>
<span className="block truncate text-xs text-fg-muted">
{ban.reason === undefined || ban.reason === ''
? t('settings.servers.bans.noReason')
: ban.reason}
{' · '}
{t('settings.servers.bans.bannedBy', {
name:
ban.actor_name === undefined || ban.actor_name === ''
? t('settings.servers.bans.unknownActor')
: ban.actor_name,
date: formatDateTime(ban.created_at, locale),
})}
</span>
</span>
<Badge>{ban.user_id}</Badge>
<Button
variant="ghost"
disabled={unban.isPending || timeout.isPending}
onClick={() => unban.mutate(ban.user_id)}
data-testid={`guild-ban-remove-${ban.user_id}`}
>
{t('settings.servers.bans.remove')}
</Button>
<Button
variant="ghost"
disabled={unban.isPending || timeout.isPending}
onClick={() => timeout.mutate({ userId: ban.user_id, minutes: timeoutMinutes })}
data-testid={`guild-ban-timeout-${ban.user_id}`}
>
{t('settings.servers.bans.timeoutAction', { minutes: timeoutMinutes })}
</Button>
</li>
))}
</ul>
)}
</SettingsSection>
);
}
interface GuildAuditSectionProps {
guildId: string;
canView: boolean;
}
/**
* «Журнал аудита» с фильтрами: действие, автор и цель (AGENT.md 7.18).
* Фильтры уходят в запрос, поэтому сервер отдаёт только нужные записи.
*/
export function GuildAuditSection({ guildId, canView }: GuildAuditSectionProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const [filters, setFilters] = useState<AuditFilters>({ limit: 50 });
const [action, setAction] = useState('');
const [actorId, setActorId] = useState('');
const [targetId, setTargetId] = useState('');
const entries = useQuery({
queryKey: guildAuditQueryKey(guildId, filters),
queryFn: ({ signal }) => fetchGuildAudit(guildId, filters, signal),
enabled: canView,
retry: 0,
});
if (!canView) {
return null;
}
const apply = (): void => {
setFilters({
limit: 50,
action: action.trim(),
actorId: actorId.trim(),
targetId: targetId.trim(),
});
};
const reset = (): void => {
setAction('');
setActorId('');
setTargetId('');
setFilters({ limit: 50 });
};
return (
<SettingsSection
title={t('settings.servers.audit.title')}
description={t('settings.servers.audit.description')}
>
<form
className="mt-4 flex flex-wrap items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
apply();
}}
>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterAction')}
<input
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder="member.ban"
data-testid="audit-filter-action"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterActor')}
<input
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={actorId}
onChange={(event) => setActorId(event.target.value)}
inputMode="numeric"
data-testid="audit-filter-actor"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterTarget')}
<input
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
inputMode="numeric"
data-testid="audit-filter-target"
/>
</label>
<Button type="submit" data-testid="audit-apply">
{t('settings.servers.audit.apply')}
</Button>
<Button variant="ghost" type="button" onClick={reset} data-testid="audit-reset">
{t('settings.servers.audit.reset')}
</Button>
</form>
{entries.isPending ? <LoadingNotice /> : null}
{entries.isError ? (
<ErrorNotice
className="mt-4"
error={entries.error}
onRetry={() => void entries.refetch()}
/>
) : null}
{entries.data !== undefined && entries.data.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-audit-empty">
{t('settings.servers.audit.empty')}
</p>
) : null}
{entries.data === undefined || entries.data.length === 0 ? null : (
<ul className="mt-4 flex flex-col gap-1 text-sm" data-testid="guild-audit">
{entries.data.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 py-2 last:border-b-0"
data-testid={`guild-audit-${entry.id}`}
>
<span className="truncate">
<span className="font-medium">{entry.action}</span>
{entry.actor_name === undefined ? null : (
<span className="text-fg-muted"> · {entry.actor_name}</span>
)}
{entry.target_id === undefined ? null : (
<span className="text-fg-muted"> → {entry.target_id}</span>
)}
{entry.reason === undefined || entry.reason === '' ? null : (
<span className="text-fg-muted"> · {entry.reason}</span>
)}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{formatDateTime(entry.created_at, locale)}
</span>
</li>
))}
</ul>
)}
</SettingsSection>
);
}
+24 -1
View File
@@ -7,7 +7,14 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canAdministerGuild, canManageEmojis, canManageSounds } from '@/lib/identity';
import { GuildAuditSection, GuildBansSection } from '@/pages/settings/GuildModerationSections';
import {
canAdministerGuild,
canBanMembers,
canManageEmojis,
canManageSounds,
canViewAuditLog,
} from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
@@ -151,6 +158,20 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
currentUser.data?.is_instance_admin === true,
);
// «Баны» и журнал аудита (AGENT.md 7.17, 7.18).
const canBan = canBanMembers(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
const canViewAudit = canViewAuditLog(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
const canManageSoundSet = canManageSounds(
guild.my_permissions,
@@ -175,6 +196,8 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
{canManageSoundSet ? (
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
) : null}
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
<GuildDangerSection
key={`${guild.id}-danger`}
guildId={guild.id}
+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();
});
});
});