feat(social): блокировка пользователей

- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
This commit is contained in:
2026-09-21 19:21:00 +03:00
parent afcdf9c1c4
commit ed98c78c9e
11 changed files with 461 additions and 5 deletions
+10
View File
@@ -107,6 +107,16 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Личная беседа: блокировка запрещает переписку в обе стороны (AGENT.md 7.2).
if channel.GuildID == nil {
blocked, err := s.store.DMBlocked(ctx, channelID, user.ID)
if err != nil {
return nil, humaError(err)
}
if blocked {
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
}
}
// Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя, // Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя,
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19). // администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
if !user.IsInstanceAdmin { if !user.IsInstanceAdmin {
+71
View File
@@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Блокировка сильнее заявки: переписка и дружба с ней невозможны.
if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil {
return nil, humaError(err)
} else if blocked {
return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked")
}
// Если встречная заявка уже есть — сразу становимся друзьями. // Если встречная заявка уже есть — сразу становимся друзьями.
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID) reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
switch { switch {
@@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) {
return newOKOutput(), nil return newOKOutput(), nil
}) })
huma.Register(api, huma.Operation{
OperationID: "blockUser",
Method: http.MethodPut,
Path: "/users/@me/blocks/{user_id}",
Summary: "Заблокировать пользователя",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if targetID == user.ID {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself")
}
if _, err := s.store.GetUser(ctx, targetID); err != nil {
return nil, humaError(err)
}
// Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2).
if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil {
return nil, humaError(err)
}
s.dispatchRelationshipUpdate(user.ID, targetID)
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "unblockUser",
Method: http.MethodDelete,
Path: "/users/@me/blocks/{user_id}",
Summary: "Снять блокировку",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if err := s.store.UnblockUser(ctx, user.ID, targetID); err != nil {
return nil, humaError(err)
}
s.dispatchRelationshipUpdate(user.ID, targetID)
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "openDirectChannel", OperationID: "openDirectChannel",
Method: http.MethodPost, Method: http.MethodPost,
@@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
if err != nil { if err != nil {
return nil, humaError(err) return nil, humaError(err)
} }
// С заблокированным беседа не открывается (AGENT.md 7.2).
if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil {
return nil, humaError(err)
} else if blocked {
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
}
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID) channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID) channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
+81
View File
@@ -521,3 +521,84 @@ func TestMembersCarrySystemBadges(t *testing.T) {
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String()) t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
} }
} }
func TestBlockUserStopsDMsAndRequests(t *testing.T) {
srv, owner, member, _, memberID := cosmeticsFixture(t)
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
// Заводим дружбу, затем блокируем: связь снимается, остаётся блокировка.
request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
`{"user_id":"`+memberID+`"}`, owner)
if request.Code != http.StatusOK {
t.Fatalf("заявка = %d (%s)", request.Code, request.Body.String())
}
if accept := doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member); accept.Code != http.StatusOK {
t.Fatalf("принятие = %d", accept.Code)
}
blocked := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner)
if blocked.Code != http.StatusOK {
t.Fatalf("блокировка = %d (%s)", blocked.Code, blocked.Body.String())
}
relations := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner)
list := decodeResponse[struct {
Friends []struct {
UserID string `json:"user_id"`
} `json:"friends"`
Blocked []struct {
UserID string `json:"user_id"`
} `json:"blocked"`
}](t, relations)
if len(list.Friends) != 0 || len(list.Blocked) != 1 || list.Blocked[0].UserID != memberID {
t.Fatalf("после блокировки: друзей %d, заблокированных %d", len(list.Friends), len(list.Blocked))
}
// Заявка в друзья от заблокированного отклоняется.
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
`{"user_id":"`+ownerID+`"}`, member)
if again.Code != http.StatusForbidden {
t.Fatalf("заявка от заблокированного = %d, ожидался 403 (%s)", again.Code, again.Body.String())
}
// Личная беседа не открывается и сообщения в неё не уходят.
open := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+memberID+`"}`, owner)
if open.Code != http.StatusForbidden {
t.Fatalf("беседа с заблокированным = %d, ожидался 403 (%s)", open.Code, open.Body.String())
}
// Снятие блокировки возвращает возможность писать.
if unblock := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/blocks/"+memberID, "", owner); unblock.Code != http.StatusOK {
t.Fatalf("снятие блокировки = %d", unblock.Code)
}
reopened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+memberID+`"}`, owner)
if reopened.Code != http.StatusOK {
t.Fatalf("беседа после снятия блокировки = %d (%s)", reopened.Code, reopened.Body.String())
}
channelID := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, reopened).Channel.ID
// Блокируем снова уже при существующей беседе: сообщение не отправляется.
if rec := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner); rec.Code != http.StatusOK {
t.Fatalf("повторная блокировка = %d", rec.Code)
}
post := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"привет"}`, owner)
if post.Code != http.StatusForbidden {
t.Fatalf("сообщение заблокированному = %d, ожидался 403 (%s)", post.Code, post.Body.String())
}
if code := errorCodeOf(t, post); code != "dm.blocked" {
t.Fatalf("код ошибки = %q", code)
}
_ = ownerID
}
+125
View File
@@ -0,0 +1,125 @@
package store
import (
"context"
)
// Блокировка пользователя (AGENT.md 7.2, 8.2): запрещает личные сообщения и
// заявки в друзья. Хранится той же таблицей связей, что друзья: тип `blocked`
// сильнее остальных, поэтому при блокировке встречные связи снимаются.
// BlockUser блокирует пользователя: дружба и заявки в обе стороны снимаются,
// у блокирующего остаётся связь `blocked`.
func (s *Store) BlockUser(ctx context.Context, userID, targetID uint64) error {
if userID == targetID {
return ErrConflict
}
tx, err := s.writer.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// Снимаем все встречные связи: и дружбу, и заявки.
if _, err := tx.ExecContext(ctx,
`DELETE FROM relationships WHERE (user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?)`,
int64(userID), int64(targetID), int64(targetID), int64(userID)); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO relationships (user_id, target_id, type, created_at, updated_at)
VALUES (?, ?, 'blocked', ?, ?)`,
int64(userID), int64(targetID), s.Now(), s.Now()); err != nil {
return err
}
return tx.Commit()
}
// UnblockUser снимает блокировку; дружбу после неё нужно заводить заново.
func (s *Store) UnblockUser(ctx context.Context, userID, targetID uint64) error {
relation, err := s.GetRelationship(ctx, userID, targetID)
if err != nil {
return err
}
if relation.Type != RelationshipBlocked {
return ErrNotFound
}
return s.RemoveRelationship(ctx, userID, targetID)
}
// IsBlocked сообщает, блокирует ли пользователь другого (в любую сторону).
func (s *Store) IsBlocked(ctx context.Context, userID, targetID uint64) (bool, error) {
var count int
err := s.reader.QueryRowContext(ctx, `
SELECT COUNT(*) FROM relationships
WHERE type = 'blocked'
AND ((user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?))`,
int64(userID), int64(targetID), int64(targetID), int64(userID)).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// ListBlocked возвращает заблокированных пользователей (для списка в клиенте).
func (s *Store) ListBlocked(ctx context.Context, userID uint64) ([]RelationshipProfile, error) {
return s.ListRelationships(ctx, userID, RelationshipBlocked)
}
// BlockedIDs возвращает множество пользователей, которые заблокированы
// пользователем: нужно для фильтрации поиска и списков.
func (s *Store) BlockedIDs(ctx context.Context, userID uint64) (map[uint64]bool, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT target_id FROM relationships WHERE user_id = ? AND type = 'blocked'`, int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
ids := map[uint64]bool{}
for rows.Next() {
var id uint64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids[id] = true
}
return ids, rows.Err()
}
// DMBlocked проверяет, есть ли среди участников личной беседы тот, кто
// блокирует пользователя или кого блокирует он сам: переписка запрещена
// (AGENT.md 7.2).
func (s *Store) DMBlocked(ctx context.Context, channelID, userID uint64) (bool, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT user_id FROM dm_participants WHERE channel_id = ?`, int64(channelID))
if err != nil {
return false, err
}
defer rows.Close()
participants := make([]uint64, 0, 2)
for rows.Next() {
var id uint64
if err := rows.Scan(&id); err != nil {
return false, err
}
participants = append(participants, id)
}
if err := rows.Err(); err != nil {
return false, err
}
for _, participant := range participants {
if participant == userID {
continue
}
blocked, err := s.IsBlocked(ctx, userID, participant)
if err != nil {
return false, err
}
if blocked {
return true, nil
}
}
return false, nil
}
+14
View File
@@ -188,6 +188,20 @@ export async function acceptFriendRequest(userId: string): Promise<void> {
}); });
} }
/** `PUT /users/@me/blocks/{id}` — заблокировать (AGENT.md 7.2). */
export async function blockUser(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
method: 'PUT',
});
}
/** `DELETE /users/@me/blocks/{id}` — снять блокировку. */
export async function unblockUser(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
}
/** /**
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или * `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка — * отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
+10 -4
View File
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends'; import { blockUser, removeRelationship, type RelationshipUser } from '@/api/friends';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges'; import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -33,6 +33,11 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
const nameRef = useRef<HTMLButtonElement | null>(null); const nameRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLButtonElement | null>(null); const menuRef = useRef<HTMLButtonElement | null>(null);
const block = useMutation({
mutationFn: () => blockUser(friend.user_id),
onSuccess: () => reload(),
});
const remove = useMutation({ const remove = useMutation({
mutationFn: () => removeRelationship(friend.user_id), mutationFn: () => removeRelationship(friend.user_id),
onSuccess: () => reload(), onSuccess: () => reload(),
@@ -180,11 +185,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
<MenuItem <MenuItem
danger danger
icon="⛔" icon="⛔"
disabled={remove.isPending} data-testid={`friend-block-${friend.user_id}`}
disabled={block.isPending}
onClick={() => { onClick={() => {
setMenuOpen(false); setMenuOpen(false);
// Отдельной ручки блокировки нет: блокировка — тот же DELETE. // Блокировка запрещает личные сообщения и снимает дружбу (AGENT.md 7.2).
remove.mutate(); block.mutate();
}} }}
> >
{t('friends.list.block')} {t('friends.list.block')}
+56 -1
View File
@@ -2,8 +2,14 @@ import { useEffect, useId, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { acceptFriendRequest, removeRelationship, type RelationshipUser } from '@/api/friends'; import {
acceptFriendRequest,
removeRelationship,
unblockUser,
type RelationshipUser,
} from '@/api/friends';
import { FriendRow } from '@/components/friends/FriendRow'; import { FriendRow } from '@/components/friends/FriendRow';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { InviteJoin } from '@/components/friends/InviteJoin'; import { InviteJoin } from '@/components/friends/InviteJoin';
import { UserSearch } from '@/components/friends/UserSearch'; import { UserSearch } from '@/components/friends/UserSearch';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
@@ -29,6 +35,7 @@ export function FriendsView() {
const friends = useFriendsStore((state) => state.friends); const friends = useFriendsStore((state) => state.friends);
const incoming = useFriendsStore((state) => state.incoming); const incoming = useFriendsStore((state) => state.incoming);
const outgoing = useFriendsStore((state) => state.outgoing); const outgoing = useFriendsStore((state) => state.outgoing);
const blocked = useFriendsStore((state) => state.blocked);
const status = useFriendsStore((state) => state.status); const status = useFriendsStore((state) => state.status);
const error = useFriendsStore((state) => state.error); const error = useFriendsStore((state) => state.error);
const load = useFriendsStore((state) => state.load); const load = useFriendsStore((state) => state.load);
@@ -44,6 +51,12 @@ export function FriendsView() {
void load(); void load();
}, [load]); }, [load]);
// Снятие блокировки: список перечитывается, человек снова может писать.
const unblock = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => reload(),
});
const respond = useMutation({ const respond = useMutation({
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) => mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId), kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
@@ -222,7 +235,49 @@ export function FriendsView() {
</section> </section>
)} )}
{blocked.length === 0 ? null : (
<section aria-label={t('friends.blocked.section')} className="mt-4">
<h3 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.blocked.section', { count: blocked.length })}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-0.5" data-testid="friends-blocked">
{blocked.map((user) => (
<li
key={user.user_id}
data-testid={`blocked-row-${user.user_id}`}
className="flex items-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 hover:bg-surface-2"
>
<FramedAvatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
cosmetics={user.cosmetics}
/>
<span className="min-w-0 flex-1">
<Nickname
name={displayName(user)}
cosmetics={user.cosmetics}
className="text-sm font-medium"
/>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<Button
variant="ghost"
data-testid={`blocked-unblock-${user.user_id}`}
disabled={unblock.isPending}
onClick={() => unblock.mutate(user.user_id)}
>
{t('friends.blocked.unblock')}
</Button>
</li>
))}
</ul>
</section>
)}
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null} {respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
{unblock.isError ? <ErrorNotice className="mt-3" error={unblock.error} /> : null}
<InviteJoin className="mt-8" /> <InviteJoin className="mt-8" />
</section> </section>
+5
View File
@@ -418,6 +418,11 @@
"title": "Conversation unavailable", "title": "Conversation unavailable",
"body": "This direct conversation was not found: it may be deleted or closed to you." "body": "This direct conversation was not found: it may be deleted or closed to you."
} }
},
"blocked": {
"section": "Blocked ({{count}})",
"unblock": "Unblock",
"alreadyBlocked": "Blocked"
} }
}, },
"invites": { "invites": {
+5
View File
@@ -418,6 +418,11 @@
"title": "Беседа недоступна", "title": "Беседа недоступна",
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт." "body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
} }
},
"blocked": {
"section": "Заблокированные ({{count}})",
"unblock": "Разблокировать",
"alreadyBlocked": "Заблокирован"
} }
}, },
"invites": { "invites": {
@@ -9,7 +9,9 @@ import {
kickGuildMember, kickGuildMember,
timeoutGuildMember, timeoutGuildMember,
} from '@/api/moderation'; } from '@/api/moderation';
import { blockUser } from '@/api/friends';
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu'; import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { useFriendsStore } from '@/stores/friends';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */ /** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
@@ -64,6 +66,17 @@ export function MemberModerationMenu({
close(); close();
}, },
}); });
const reloadRelationships = useFriendsStore((state) => state.reload);
const blockedUsers = useFriendsStore((state) => state.blocked);
const blocked = blockedUsers.some((entry) => entry.user_id === userId);
const block = useMutation({
mutationFn: () => blockUser(userId),
onSuccess: () => {
void reloadRelationships();
},
});
const kick = useMutation({ const kick = useMutation({
mutationFn: () => kickGuildMember(guildId, userId), mutationFn: () => kickGuildMember(guildId, userId),
onSuccess: () => { onSuccess: () => {
@@ -162,6 +175,16 @@ export function MemberModerationMenu({
</MenuItem> </MenuItem>
) : null} ) : null}
{/* Личная блокировка не зависит от прав на сервере (AGENT.md 7.2). */}
<MenuItem
danger
disabled={pending || blocked}
data-testid={`member-block-${userId}`}
onClick={() => block.mutate()}
>
{t(blocked ? 'friends.blocked.alreadyBlocked' : 'friends.list.block')}
</MenuItem>
{error === null || error === undefined ? null : ( {error === null || error === undefined ? null : (
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p> <p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
)} )}
+61
View File
@@ -15,6 +15,7 @@ import {
messagesRoutes, messagesRoutes,
recordedRequests, recordedRequests,
renderApp, renderApp,
waitForPage,
type FetchRoute, type FetchRoute,
} from './helpers'; } from './helpers';
@@ -891,3 +892,63 @@ describe('события шлюза о друзьях', () => {
}); });
}); });
}); });
describe('блокировка пользователей', () => {
it('показывает заблокированных и снимает блокировку', async () => {
const blocked = relationship({
user_id: 'user-7',
username: 'spammer',
display_name: 'Spammer',
relationship: 'blocked',
});
const fetchMock = installFetch(
baseRoutes({ friends: [], incoming: [], outgoing: [], blocked: [blocked] }, [
{
match: '/api/v1/users/@me/blocks/user-7',
method: 'DELETE',
response: () => json({ ok: true }),
},
]),
);
renderApp('/app/friends');
await waitForPage('Друзья');
const section = await screen.findByTestId('friends-blocked');
expect(within(section).getByText('Spammer')).toBeVisible();
await userEvent.click(screen.getByTestId('blocked-unblock-user-7'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'DELETE' && request.url.includes('/blocks/user-7'),
),
).toBe(true);
});
});
it('блокирует друга из меню строки', async () => {
const fetchMock = installFetch(
baseRoutes({ friends: [bob], incoming: [], outgoing: [], blocked: [] }, [
{
match: '/api/v1/users/@me/blocks/user-2',
method: 'PUT',
response: () => json({ ok: true }),
},
]),
);
renderApp('/app/friends');
await waitForPage('Друзья');
await userEvent.click(await screen.findByTestId('friends-list-toggle'));
await userEvent.click(await screen.findByLabelText('Действия: Боб'));
await userEvent.click(await screen.findByTestId('friend-block-user-2'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'PUT' && request.url.includes('/blocks/user-2'),
),
).toBe(true);
});
});
});