bcfc275bb3
Раздел «Друзья» по запросу пользователя: - первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы не засоряют список серверов; - сайдбар друзей: беседы с точками статуса и бейджами непрочитанного, список друзей с кликом на беседу; - контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по логину (debounce), добавление в друзья, входящие и исходящие заявки, меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе пользователя (по умолчанию МСК), блок «Присоединиться по приглашению» с предпросмотром сервера; - личный чат на существующих компонентах: права по can_send канала, заголовок с именем и статусом собеседника, поиск, пины, ack и typing работают; - realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE, PRESENCE_UPDATE и MESSAGE_CREATE в беседах; - новый пользователь: «У вас пока нет серверов», создание сервера и вход по приглашению (автовступления нет); - 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
803 lines
28 KiB
TypeScript
803 lines
28 KiB
TypeScript
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',
|
||
});
|
||
});
|
||
});
|