Files
glchat/web/tests/friends.test.tsx
T
grendervill 324d2aac72 fix(web): DM_CHANNEL_UPDATE и DM_CHANNEL_DELETE обновляют список бесед
Сервер присылал эти события и раньше, но клиент перечитывал список бесед только
по `DM_CHANNEL_CREATE` и READY: добавление участника, переименование группы и
выход из неё не появлялись в сайдбаре до перезагрузки, а удалённая беседа
оставалась открытой.

Теперь все три события перечитывают список (`DM_CHANNEL_UPDATE` меняет состав,
имя и аватар группы сразу), а `DM_CHANNEL_DELETE` дополнительно убирает беседу
из стора сессии и закрывает её, если она была открыта.

Тесты в `web/tests/friends.test.tsx` падают на старом коде: переименование
группы видно второму участнику, удалённая беседа исчезает и закрывается.
2026-09-26 16:16:10 +03:00

1013 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { RelationshipUser, Relationships } from '@/api/friends';
import type { User } from '@/api/types';
import {
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
recordedRequests,
renderApp,
waitForPage,
type FetchRoute,
} from './helpers';
/**
* Раздел «Друзья» и личные беседы: рейка, пустое состояние, поиск, заявки,
* статусы, поповер последнего входа, DM-чат, приглашения и события шлюза.
*/
const user = makeUser();
/** Инстанс с «главным» сервером: автовступления в него больше быть не должно. */
const instance = {
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
main_guild_id: 'g-main',
},
};
const empty: Relationships = { friends: [], incoming: [], outgoing: [], blocked: [] };
/** Пользователь со связью: значения по умолчанию — «друг в сети». */
function relationship(
overrides: Partial<RelationshipUser> & { user_id: string },
): RelationshipUser {
const username = overrides.username ?? overrides.user_id;
return {
username,
display_name: username,
status: 'online',
visible_status: 'online',
is_instance_admin: false,
badges: [],
timezone: 'Europe/Moscow',
relationship: 'friend',
...overrides,
};
}
const bob = relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' });
/** Базовые ответы оболочки: профиль, инстанс, серверы, связи. */
function baseRoutes(
relationships: Relationships | (() => Relationships) = empty,
extra: FetchRoute[] = [],
): FetchRoute[] {
return [
...extra,
{
match: '/api/v1/users/@me/relationships',
response: () => json(typeof relationships === 'function' ? relationships() : relationships),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json(instance) },
];
}
/** Личная беседа в REST-форме (`GET /users/@me/channels`). */
function directChannel(id: string, displayName = 'Боб', status = 'online') {
return {
id,
type: 'dm',
can_send: true,
can_view: true,
recipient: {
user_id: 'user-2',
username: 'bob',
display_name: displayName,
status,
visible_status: status,
timezone: 'Europe/Moscow',
},
};
}
/**
* Событие шлюза внутри `act`: обработчик обновляет сторы, а часть работы
* (перечитывание списков) завершается промисами.
*/
async function dispatchEvent(
gateway: ReturnType<typeof installGatewaySocket>,
type: string,
payload: unknown,
): Promise<void> {
await act(async () => {
gateway.dispatch(type, payload);
await Promise.resolve();
});
}
/**
* Раскрывает список друзей: по умолчанию он свёрнут под кнопкой
* «Друзья (N)» и разворачивается снизу вверх.
*/
async function expandFriends(): Promise<HTMLElement> {
const toggle = await screen.findByTestId('friends-list-toggle');
if (toggle.getAttribute('aria-expanded') !== 'true') {
await userEvent.click(toggle);
}
return screen.findByTestId('friends-list');
}
describe('раздел друзей', () => {
it('первой иконкой рейки открывает раздел друзей', async () => {
const visitor = userEvent.setup();
const snapshot = makeReadySnapshot([
{ id: 'g-1', name: 'Сервер', channels: [{ id: 'c-1', name: 'общий' }] },
]);
installFetch(baseRoutes(empty, messagesRoutes('c-1', [])));
const gateway = installGatewaySocket(snapshot);
const { router } = renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await screen.findByLabelText('Открыть комнату общий');
// Друзья — выше списка серверов.
const friendsIcon = screen.getByTestId('friends-rail-link');
expect(friendsIcon.compareDocumentPosition(screen.getByTestId('guild-rail'))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(friendsIcon).toHaveAccessibleName('Друзья');
await visitor.click(friendsIcon);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends');
});
// Сайдбар раздела: заголовок «Друзья».
expect(await screen.findByRole('heading', { level: 1, name: 'Друзья' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('aria-current', 'page');
});
it('без друзей и бесед показывает пустое состояние с кнопкой поиска', async () => {
installFetch(baseRoutes(empty));
renderApp('/app/friends');
expect(await screen.findByText('Добавь друзей для общения')).toBeVisible();
expect(screen.getByRole('button', { name: 'Найти друзей' })).toBeVisible();
expect(screen.queryByTestId('dm-list')).toBeNull();
expect(screen.getByTestId('dm-sidebar-empty')).toBeVisible();
});
it('ищет пользователей по логину и отправляет заявку в друзья', async () => {
const visitor = userEvent.setup();
let requested = false;
const fetchMock = installFetch(
baseRoutes(
() => (requested ? { ...empty, outgoing: [bob] } : empty),
[
{
match: '/api/v1/users/search',
response: () =>
json({
users: [
relationship({
user_id: 'user-2',
username: 'bob',
display_name: 'Боб',
relationship: 'none',
}),
],
}),
},
{
match: '/api/v1/users/@me/relationships',
method: 'POST',
response: () => {
requested = true;
return json({ ok: true });
},
},
],
),
);
renderApp('/app/friends');
await visitor.click(await screen.findByRole('button', { name: 'Найти друзей' }));
const input = await screen.findByLabelText('Поиск по логину');
await visitor.type(input, 'bob');
// Debounce 400 мс: запрос уходит после паузы в наборе.
expect(await screen.findByText('Боб', {}, { timeout: 3000 })).toBeVisible();
expect(screen.getByText('@bob')).toBeVisible();
expect(findRequest(fetchMock, { url: '/users/search' })?.url).toContain('q=bob');
await visitor.click(screen.getByRole('button', { name: 'Добавить в друзья' }));
expect(await screen.findByText('Заявка отправлена')).toBeVisible();
expect(
findRequest(fetchMock, { url: '/users/@me/relationships', method: 'POST' })?.body,
).toEqual({ username: 'bob' });
});
it('принимает входящую заявку и отменяет исходящую', async () => {
const visitor = userEvent.setup();
let accepted = false;
const fetchMock = installFetch(
baseRoutes(
() => ({
friends: accepted
? [relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол' })]
: [],
incoming: accepted
? []
: [
relationship({
user_id: 'user-3',
username: 'carol',
display_name: 'Кэрол',
relationship: 'incoming',
}),
],
outgoing: [
relationship({
user_id: 'user-4',
username: 'dave',
display_name: 'Дэйв',
relationship: 'outgoing',
}),
],
blocked: [],
}),
[
{
match: '/api/v1/users/@me/relationships/user-3/accept',
method: 'POST',
response: () => {
accepted = true;
return json({ ok: true });
},
},
{
match: '/api/v1/users/@me/relationships/user-4',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
),
);
renderApp('/app/friends');
const incoming = await screen.findByTestId('friends-incoming');
expect(incoming).toHaveTextContent('@carol');
await visitor.click(within(incoming).getByRole('button', { name: 'Принять' }));
expect(
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
).toBeDefined();
await expandFriends();
expect(screen.getByTestId('friends-list')).toHaveTextContent('Кэрол');
const outgoing = screen.getByTestId('friends-outgoing');
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/users/@me/relationships/user-4', method: 'DELETE' }),
).toBeDefined();
});
});
it('отклоняет входящую заявку тем же DELETE', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
baseRoutes(
{
...empty,
incoming: [
relationship({
user_id: 'user-3',
username: 'carol',
display_name: 'Кэрол',
relationship: 'incoming',
}),
],
},
[
{
match: '/api/v1/users/@me/relationships/user-3',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
),
);
renderApp('/app/friends');
const incoming = await screen.findByTestId('friends-incoming');
await visitor.click(within(incoming).getByRole('button', { name: 'Отклонить' }));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/users/@me/relationships/user-3', method: 'DELETE' }),
).toBeDefined();
});
});
it('список друзей свёрнут по умолчанию и раскрывается кнопкой', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
method: 'POST',
response: () => json({ channel: directChannel('dm-1') }),
},
]),
);
const { router } = renderApp('/app/friends');
// Свёрнут: списка в DOM нет, кнопка сообщает состояние и число друзей.
const toggle = await screen.findByTestId('friends-list-toggle');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle).toHaveTextContent('Друзья (1)');
expect(screen.queryByTestId('friends-list')).toBeNull();
expect(screen.getByTestId('friends-list-region')).toHaveAttribute('data-state', 'closed');
// Развёрнутый список въезжает снизу: класс анимации и состояние open.
await visitor.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
const region = screen.getByTestId('friends-list-region');
expect(region).toHaveAttribute('data-state', 'open');
expect(region).toHaveClass('gl-reveal-up');
// Развёрнутый список работает как раньше: переход в беседу по клику.
const list = await screen.findByTestId('friends-list');
await visitor.click(within(list).getByTestId('friend-name-user-2'));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
});
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
recipient_id: 'user-2',
});
// На странице беседы списка нет: состояние раскрытия переживает возврат.
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('true');
await visitor.click(screen.getByTestId('friends-rail-link'));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends');
});
// Свёрнутое состояние тоже запоминается.
await visitor.click(await screen.findByTestId('friends-list-toggle'));
expect(screen.queryByTestId('friends-list')).toBeNull();
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('false');
});
it('заявки в друзья остаются видимыми при свёрнутом списке', async () => {
installFetch(
baseRoutes({
...empty,
friends: [bob],
incoming: [
relationship({
user_id: 'user-3',
username: 'carol',
display_name: 'Кэрол',
relationship: 'incoming',
}),
],
}),
);
renderApp('/app/friends');
expect(await screen.findByTestId('friends-incoming')).toHaveTextContent('Кэрол');
expect(await screen.findByTestId('friends-list-toggle')).toHaveAttribute(
'aria-expanded',
'false',
);
// Бейджи непрочитанного в беседах живут в сайдбаре и от списка не зависят.
expect(screen.queryByTestId('friends-list')).toBeNull();
});
it('рисует цветные точки статусов в списке друзей', async () => {
installFetch(
baseRoutes({
friends: [
relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' }),
relationship({
user_id: 'user-3',
username: 'carol',
display_name: 'Кэрол',
status: 'idle',
visible_status: 'idle',
}),
relationship({
user_id: 'user-4',
username: 'dave',
display_name: 'Дэйв',
status: 'dnd',
visible_status: 'dnd',
}),
relationship({
user_id: 'user-5',
username: 'erin',
display_name: 'Эрин',
status: 'offline',
visible_status: 'offline',
}),
relationship({
user_id: 'user-6',
username: 'frank',
display_name: 'Фрэнк',
status: 'invisible',
visible_status: 'invisible',
}),
],
incoming: [],
outgoing: [],
blocked: [],
}),
);
renderApp('/app/friends');
const list = await expandFriends();
const dots: [string, string, string][] = [
['user-2', 'online', 'bg-success'],
['user-3', 'idle', 'bg-warning'],
['user-4', 'dnd', 'bg-fg-muted'],
['user-5', 'offline', 'bg-danger'],
['user-6', 'invisible', 'bg-info'],
];
for (const [id, status, className] of dots) {
const dot = within(list).getByTestId(`friend-status-${id}`);
expect(dot).toHaveAttribute('data-status', status);
expect(dot).toHaveClass(className);
}
});
it('показывает последний вход в часовом поясе пользователя (МСК по умолчанию)', async () => {
const visitor = userEvent.setup();
installFetch(
baseRoutes({
friends: [
relationship({
user_id: 'user-2',
username: 'bob',
display_name: 'Боб',
status: 'offline',
visible_status: 'offline',
last_seen_at: '2026-09-19T10:00:00Z',
}),
relationship({
user_id: 'user-5',
username: 'erin',
display_name: 'Эрин',
status: 'offline',
visible_status: 'offline',
}),
relationship({ user_id: 'user-6', username: 'frank', display_name: 'Фрэнк' }),
],
incoming: [],
outgoing: [],
blocked: [],
}),
);
renderApp('/app/friends');
await expandFriends();
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
await visitor.hover(screen.getByTestId('friend-name-user-2'));
expect(await screen.findByText('Был(а) в сети: 19.09.2026, 13:00')).toBeVisible();
await visitor.unhover(screen.getByTestId('friend-name-user-2'));
// Подсказка доступна и с клавиатуры: фокус на имени открывает поповер.
const erin = screen.getByTestId('friend-name-user-5');
act(() => {
erin.focus();
});
expect(await screen.findByText('Не в сети давно')).toBeVisible();
act(() => {
erin.blur();
});
// Пользователь в сети — время последнего входа не показываем.
await visitor.hover(screen.getByTestId('friend-name-user-6'));
expect(await screen.findByText('Сейчас в сети')).toBeVisible();
});
it('переводит время последнего входа в пояс из настроек профиля', async () => {
const visitor = userEvent.setup();
const ekbUser: User = makeUser({ timezone: 'Asia/Yekaterinburg' });
installFetch(
baseRoutes(
{
friends: [
relationship({
user_id: 'user-2',
username: 'bob',
display_name: 'Боб',
status: 'offline',
visible_status: 'offline',
last_seen_at: '2026-09-19T10:00:00Z',
}),
],
incoming: [],
outgoing: [],
blocked: [],
},
// Профиль с поясом из настроек: маршруты из `extra` проверяются раньше.
[{ match: '/api/v1/users/@me', response: () => json({ user: ekbUser }) }],
),
);
renderApp('/app/friends');
await expandFriends();
await visitor.hover(screen.getByTestId('friend-name-user-2'));
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
expect(await screen.findByText('Был(а) в сети: 19.09.2026, 15:00')).toBeVisible();
});
it('открывает личную беседу и отправляет сообщение', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
method: 'POST',
response: () => json({ channel: directChannel('dm-1') }),
},
...messagesRoutes(
'dm-1',
[],
[
{
match: '/api/v1/channels/dm-1/messages',
method: 'POST',
response: () =>
json({
message: makeMessage({
id: 'm-1',
channel_id: 'dm-1',
author_id: 'user-1',
content: 'привет',
}),
}),
},
],
),
]),
);
const { router } = renderApp('/app/friends');
const list = await expandFriends();
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
});
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
recipient_id: 'user-2',
});
// Шапка беседы: имя собеседника и точка статуса.
expect(await screen.findByRole('heading', { name: 'Боб' })).toBeVisible();
expect(screen.getByTestId('dm-status')).toHaveAttribute('data-status', 'online');
const composer = await screen.findByTestId('composer');
await visitor.type(within(composer).getByRole('textbox'), 'привет');
await visitor.click(within(composer).getByRole('button', { name: 'Отправить' }));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/api/v1/channels/dm-1/messages', method: 'POST' })?.body,
).toMatchObject({ content: 'привет' });
});
expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
// Композер в личной беседе работает без прав сервера.
expect(within(composer).getByRole('textbox')).toHaveAttribute('placeholder', 'Написать Боб');
});
it('открывает беседу по прямой ссылке, если её нет в снапшоте', async () => {
installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [directChannel('dm-7', 'Кэрол')] }),
},
...messagesRoutes('dm-7', []),
]),
);
renderApp('/app/friends/dm-7');
// Список бесед подтягивается по REST — беседа открывается.
expect(await screen.findByRole('heading', { name: 'Кэрол' })).toBeVisible();
expect(await screen.findByTestId('composer')).toBeVisible();
});
it('присоединяется по приглашению: предпросмотр и сервер в рейке', async () => {
const visitor = userEvent.setup();
const guildDetail = {
id: 'g-inv',
name: 'Приглашённые',
description: '',
owner_id: 'user-9',
is_main: false,
member_count: 3,
roles: [],
my_role_ids: [],
my_permissions: ['SEND_MESSAGES'],
};
const fetchMock = installFetch(
baseRoutes(empty, [
{
match: '/api/v1/invites/ab12cd',
response: () =>
json({
invite: { code: 'ab12cd', guild_id: 'g-inv' },
guild: { id: 'g-inv', name: 'Приглашённые', member_count: 3, is_member: false },
}),
},
{
match: '/api/v1/invites/ab12cd',
method: 'POST',
response: () => json({ guild_id: 'g-inv' }),
},
{
match: '/api/v1/guilds/g-inv/channels',
response: () =>
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
},
{ match: '/api/v1/guilds/g-inv/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-inv/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-inv', response: () => json({ guild: guildDetail }) },
...messagesRoutes('c-1', []),
]),
);
const { router } = renderApp('/app/friends');
const input = await screen.findByLabelText('Код или ссылка приглашения');
await visitor.type(input, 'https://gl.localhost/invite/ab12cd');
const preview = await screen.findByTestId('invite-preview', {}, { timeout: 3000 });
expect(preview).toHaveTextContent('Приглашённые');
expect(preview).toHaveTextContent('Участников: 3');
await visitor.click(screen.getByRole('button', { name: 'Присоединиться' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/invites/ab12cd', method: 'POST' })).toBeDefined();
});
// Сервер появился в рейке, открылся его первый канал.
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/g-inv/c-1');
});
const rail = screen.getByTestId('guild-rail');
expect(await within(rail).findByLabelText('Открыть сервер Приглашённые')).toBeVisible();
});
it('сообщает о недействительном приглашении', async () => {
const visitor = userEvent.setup();
installFetch(
baseRoutes(empty, [
{
match: '/api/v1/invites/dead',
response: () =>
json({ error: { code: 'invite.expired', message: 'invite has expired' } }, 410),
},
]),
);
renderApp('/app/friends');
await visitor.type(await screen.findByLabelText('Код или ссылка приглашения'), 'dead');
expect(
await screen.findByText('Срок действия приглашения истёк.', {}, { timeout: 3000 }),
).toBeVisible();
});
it('не вступает ни в какой сервер автоматически', async () => {
const fetchMock = installFetch(baseRoutes(empty));
const gateway = installGatewaySocket(makeReadySnapshot([]));
renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
expect(await screen.findByTestId('friends-empty')).toBeVisible();
expect(screen.getByTestId('guild-rail').children).toHaveLength(0);
// «Главного сервера» в рейке больше нет: только приглашение.
expect(recordedRequests(fetchMock).some((request) => request.url.includes('/join'))).toBe(
false,
);
expect(screen.getByTestId('invite-join')).toBeVisible();
});
});
describe('события шлюза о друзьях', () => {
it('DM_CHANNEL_CREATE перечитывает список бесед', async () => {
let created = false;
installFetch(
baseRoutes(empty, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: created ? [directChannel('dm-1')] : [] }),
},
]),
);
const gateway = installGatewaySocket(makeReadySnapshot([]));
renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible();
created = true;
await dispatchEvent(gateway, 'DM_CHANNEL_CREATE', { channel_id: 'dm-1' });
expect(await screen.findByTestId('dm-link-dm-1')).toBeVisible();
expect(screen.getByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'online');
});
it('DM_CHANNEL_UPDATE перечитывает список бесед', async () => {
// Состав и имя группы меняются событием: сервер шлёт только channel_id,
// поэтому список бесед перечитывается целиком (AGENT.md 7.8).
let renamed = false;
installFetch(
baseRoutes(empty, [
{
match: '/api/v1/users/@me/channels',
response: () =>
json({
channels: renamed ? [directChannel('dm-1', 'Аня и Борис')] : [],
}),
},
]),
);
const gateway = installGatewaySocket(makeReadySnapshot([]));
renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible();
renamed = true;
await dispatchEvent(gateway, 'DM_CHANNEL_UPDATE', { channel_id: 'dm-1' });
expect(await screen.findByTestId('dm-link-dm-1')).toHaveTextContent('Аня и Борис');
});
it('DM_CHANNEL_DELETE убирает беседу и закрывает её', async () => {
let removed = false;
installFetch(
baseRoutes(empty, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: removed ? [] : [directChannel('dm-1')] }),
},
]),
);
const gateway = installGatewaySocket(
makeReadySnapshot(
[],
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', status: 'online' }],
),
);
renderApp('/app/friends/dm-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
expect(await screen.findByTestId('dm-link-dm-1')).toBeVisible();
removed = true;
await dispatchEvent(gateway, 'DM_CHANNEL_DELETE', { channel_id: 'dm-1' });
// Беседа исчезла из списка, а открытая беседа закрылась: показываем список.
await waitFor(() => expect(screen.queryByTestId('dm-link-dm-1')).not.toBeInTheDocument());
expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible();
});
it('RELATIONSHIP_UPDATE перечитывает друзей', async () => {
let hasFriend = false;
installFetch(baseRoutes(() => (hasFriend ? { ...empty, friends: [bob] } : empty)));
const gateway = installGatewaySocket(makeReadySnapshot([]));
renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
expect(await screen.findByTestId('friends-empty')).toBeVisible();
hasFriend = true;
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
const list = await expandFriends();
expect(list).toHaveTextContent('Боб');
});
it('PRESENCE_UPDATE обновляет статус друга без перезагрузки', async () => {
const fetchMock = installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [directChannel('dm-1')] }),
},
]),
);
const gateway = installGatewaySocket(
makeReadySnapshot(
[],
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', status: 'online' }],
),
);
renderApp('/app/friends');
const list = await expandFriends();
await gateway.greet();
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
'data-status',
'online',
);
const before = recordedRequests(fetchMock).filter(
(request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
).length;
await dispatchEvent(gateway, 'PRESENCE_UPDATE', {
user_id: 'user-2',
status: 'dnd',
visible_status: 'dnd',
custom_status: 'занят',
last_seen_at: '2026-09-19T10:00:00Z',
});
const dot = await within(list).findByTestId('friend-status-user-2');
expect(dot).toHaveAttribute('data-status', 'dnd');
expect(dot).toHaveClass('bg-fg-muted');
// Статус собеседника обновился и в сайдбаре личных бесед.
expect(await screen.findByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'dnd');
// Перезагрузки списка друзей не было: событие применено точечно.
const after = recordedRequests(fetchMock).filter(
(request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
).length;
expect(after).toBe(before);
});
it('MESSAGE_CREATE в беседе обновляет бейдж непрочитанного', async () => {
installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [directChannel('dm-1')] }),
},
]),
);
const gateway = installGatewaySocket(
makeReadySnapshot(
[],
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-1' }],
),
);
const { router } = renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
// Отметки чтения нет: беседа не читалась — в сайдбаре точка непрочитанного.
expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
await dispatchEvent(gateway, 'MESSAGE_CREATE', {
id: 'm-2',
channel_id: 'dm-1',
author_id: 'user-2',
content: 'эй',
created_at: '2026-09-19T10:05:00Z',
});
// Новое сообщение сдвигает «последнее» в беседе — точка остаётся.
expect(screen.getByTestId('dm-unread-dm-1')).toBeVisible();
expect(router.state.location.pathname).toBe('/app/friends');
});
it('открытая беседа отмечается прочитанной через ack', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [directChannel('dm-1')] }),
},
...messagesRoutes('dm-1', [
makeMessage({
id: 'm-5',
channel_id: 'dm-1',
author_id: 'user-2',
content: 'привет',
created_at: '2026-09-19T10:05:00Z',
}),
]),
]),
);
const gateway = installGatewaySocket(
makeReadySnapshot(
[],
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-5' }],
),
);
renderApp('/app/friends');
await screen.findByTestId('guild-rail');
await gateway.greet();
// Беседа не читалась (последнее сообщение новее отметки) — есть бейдж.
expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
await visitor.click(screen.getByTestId('dm-link-dm-1'));
expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
// Беседа открыта и окно активно — уходит POST /ack с последним сообщением.
await waitFor(
() => {
expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })).toBeDefined();
},
{ timeout: 3000 },
);
expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })?.body).toEqual({
last_message_id: 'm-5',
});
});
});
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);
});
});
});