Files
glchat/web/tests/friends.test.tsx
T
grendervill bcfc275bb3 feat(web): друзья, личные беседы и вход только по приглашению
Раздел «Друзья» по запросу пользователя:

- первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы
  не засоряют список серверов;
- сайдбар друзей: беседы с точками статуса и бейджами непрочитанного,
  список друзей с кликом на беседу;
- контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по
  логину (debounce), добавление в друзья, входящие и исходящие заявки,
  меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе
  пользователя (по умолчанию МСК), блок «Присоединиться по приглашению»
  с предпросмотром сервера;
- личный чат на существующих компонентах: права по can_send канала, заголовок
  с именем и статусом собеседника, поиск, пины, ack и typing работают;
- realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE,
  PRESENCE_UPDATE и MESSAGE_CREATE в беседах;
- новый пользователь: «У вас пока нет серверов», создание сервера и вход по
  приглашению (автовступления нет);
- 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
2026-09-20 01:52:27 +03:00

803 lines
28 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,
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();
});
}
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();
expect(await screen.findByTestId('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 () => {
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 screen.findByTestId('friends-list');
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 screen.findByTestId('friends-list');
// Наведение на имя: 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 screen.findByTestId('friends-list');
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 screen.findByTestId('friends-list');
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('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 screen.findByTestId('friends-list');
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 screen.findByTestId('friends-list');
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',
});
});
});